Implementación del sistema de inicio de sesión único CAS

Prefacio

El inicio de sesión único (inicio de sesión único, denominado SSo) es actualmente una de las soluciones más populares para la integración empresarial empresarial
. Permite a los usuarios iniciar sesión solo una vez para acceder a todos los sistemas de aplicaciones de confianza mutua en múltiples sistemas de aplicaciones.

Los mecanismos de implementación de SSO son diferentes y se pueden dividir aproximadamente en dos categorías: mecanismo de cookies y mecanismo de sesión.

La sesión es un mecanismo del lado del servidor. Cuando un cliente accede al servidor, el servidor crea un
ID de sesión único para el cliente, de modo que el estado se mantenga durante todo el proceso de interacción. La información de interacción puede ser especificada por la propia aplicación, por lo que la
sesión Se utiliza el método Para implementar SSO, el inicio de sesión único no se puede lograr entre varios navegadores, pero se puede implementar en todos los dominios.

La cookie es un mecanismo del lado del cliente. El contenido que almacena incluye principalmente: nombre, valor, tiempo de vencimiento, ruta y dominio. La ruta y el dominio juntos
constituyen el alcance de la cookie. Por lo tanto, el SSO se puede implementar utilizando Cookie, pero el nombre de dominio Debe ser lo mismo.

Actualmente, la mayoría de los productos SSO utilizan el mecanismo de cookies, y CAS, el mejor producto de inicio de sesión único de código abierto, también utiliza el mecanismo de cookies
.

1. Ideas de CAS

Primero veamos una imagen: ¿Cómo implementar el inicio de sesión único con diferentes nombres de dominio?
Insertar descripción de la imagen aquí

Como se muestra en la figura anterior, los inicios de sesión entre múltiples sistemas se verificarán a través de un sistema de inicio de sesión independiente
. Es equivalente a una empresa intermediaria que integra a todos. Si desea ver una casa, solo necesita
obtener la clave con el permiso de el intermediario, logrando la unificación. Entonces esto se llama
sistema CAS. El nombre completo de CAS es Servicio de autenticación central, que es el servicio de autenticación central.
Es una solución de inicio de sesión único que se puede utilizar para el inicio de sesión único entre diferentes nombres de dominio de nivel superior
. Entonces, en nuestro curso, no es necesario destruir el código fuente de la estructura actual del proyecto,
solo necesitamos construir dos sitios estáticos para probar y usar.

Para conocer el proceso específico en CAS, consulte el siguiente diagrama de secuencia:

Insertar descripción de la imagen aquí
Análisis de proceso:

  1. El usuario accede a los recursos protegidos del sistema MTV, el sistema MTV descubre que el usuario no ha iniciado sesión, salta al centro de autenticación SSO y utiliza su propia dirección como parámetro.
  2. El centro de certificación SSO descubre que el usuario no ha iniciado sesión y salta a la página de inicio de sesión.
  3. El usuario ingresa el nombre de usuario y la contraseña para enviar la solicitud de inicio de sesión
  4. El centro de autenticación SSO verifica la información del usuario, crea una sesión global entre el usuario y el centro de autenticación SSO, un token global (que está vinculado al ID de sesión global del usuario y representa el inicio de sesión del usuario en SSO) y un token temporal (utilizado para verificar la identidad del subsistema) )
  5. El centro de autenticación SSO lleva el token y salta a la dirección de solicitud inicial del sistema MTV.
  6. El sistema MTV obtiene el token y va al centro de certificación SSO para verificar si el token es válido.
  7. El centro de autenticación SSO verifica el token y devuelve la sesión global del usuario.
  8. El sistema MTV guarda la sesión global del usuario
  9. El navegador del usuario muestra el inicio de sesión exitoso

Iniciar sesión nuevamente Diagrama de flujo:
Insertar descripción de la imagen aquí
análisis de proceso:

  1. El usuario accede a recursos protegidos del sistema MUSIC.
  2. El sistema MUSIC descubre que el usuario no ha iniciado sesión, salta al centro de certificación SSO y utiliza su propia dirección como parámetro.
  3. El centro de certificación SSO descubre que el usuario ha iniciado sesión, regresa a la dirección del sistema MUSIC y adjunta el token.
  4. El sistema MUSIC obtiene el token y va al centro de certificación SSO para verificar si el token es válido.
  5. El centro de autenticación SSO verifica el token y devuelve la sesión global del usuario.
  6. El sistema MUSIC guarda la sesión global del usuario
  7. El navegador del usuario muestra el inicio de sesión exitoso

2. Implementación del código

Implementemos el código paso a paso de acuerdo con el diagrama de tiempo anterior.

Para facilitar las pruebas, agregué la configuración del archivo de host local como acceso por nombre de dominio. Luego, se accede al código por nombre de dominio.
Directorio de archivos de host:, C:\Windows\System32\drivers\etccomo referencia

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

Preparación ambiental

Front-end
Aquí creamos dos proyectos de front-end, a saber, sso-mtv y sso-music, que
Insertar descripción de la imagen aquí
se implementaron utilizando nginx local.

Código

2.1 La interfaz del sistema MTV determina si se debe iniciar sesión en función de si existe información del usuario en la cookie de la interfaz.

// 通过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 El front-end del sistema MTV determina si el enlace lleva un ticket temporal. Si lo tiene, envía una solicitud de verificación. Si no existe, prueba que el usuario nunca ha iniciado sesión y luego salta a la página de inicio de sesión.

// 判断用户是否登录
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 Interfaz de inicio de sesión SSO, determine si la cookie contiene el token global del usuario y su existencia demuestra que el usuario ha iniciado sesión. Se emitirá un token temporal y saltará. Si no existe, saltará para iniciar sesión.

@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 Interfaz de inicio de sesión unificada SSO

/**
 * 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 Método de ticket temporal de verificación de SSO

@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 Método de cierre de sesión

@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 Controlador completo

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;
    }


}

Nota: En esta demostración, en el método de ticket temporal de verificación verificarTmpTicket durante el proceso de inicio de sesión de la nueva versión de Google Chrome, habrá una situación en la que el back-end no podrá obtener la cookie de front-end. Para obtener más detalles, consulte "Problema de pérdida de cookies de solicitud entre dominios causado por la versión del navegador Chrome "

Supongo que te gusta

Origin blog.csdn.net/qq_38055805/article/details/129746303
Recomendado
Clasificación