cas客户端接入流程

81 阅读3分钟

1 总则

1.1 编写目的

为了开发人员更好理解 CAS 统一登录流程指导开发。

1.2 读者对象

开发人员

2 后端接入指南

2.1 oauth 认证模块

2.1.1 修改依赖版本

<dependency>
<groupId>com.unicom</groupId>
<artifactId>spring-unicom-cloud-oauth2-server-stater</artifactId>
<version>2.2.1.SSO</version>
</dependency>

2.1.2 数据库修改配置

update oauth_client_details set authorized_grant_types = 'authorization_code,password,refresh_token,client_credentials,implicit,password_code,openId,mobile_password,cas_server' where id = 1;

2.2 统一登录

2.2.1 添加依赖

<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-support-springboot</artifactId>
<version>3.6.4</version>
</dependency>

2.2.2 启动类配置

@SpringBootApplication
@EnableCasClient
public class BaseAdminApplication {
    public static void main(String[] args) {}
}

2.2.3 添加配置项

#cas服务端地址
cas.server-url-prefix=https://localhost:8888/cas
##cas服务端登录地址
cas.server-login-url=https://localhost:8888/cas/login
##客户端地址
cas.client-host-url=https://127.0.0.1:8080
#拦截cas登录认证
cas.validation-url-patterns=/casLogin
cas.authentication-url-patterns=/casLogin
cas.single-logout.enabled=true
server.servlet.session.timeout=7200
cas.use-session=true

callback=${cas.server-url-prefix}/logout?service=
logout.service=http://127.0.0.1:8080/casLogoutAfter

2.2.4 编写 controller

    /**
     * 登录接口
     *
     * @return
     */
    @ApiOperation(value = "CAS系统登录")
    @RequestMapping(value = "/casLogin", method = RequestMethod.GET)
    public HttpResult login(HttpServletRequest request, HttpServletResponse response, @RequestParam("redirect") String url) throws Exception {
        Principal principal = request.getUserPrincipal();
        Map<String, Object> attributes = ((AttributePrincipal) principal).getAttributes();
        String username = (String) attributes.get("username");
        if(StringUtils.isEmpty(username)){
            username = (String) attributes.get("userName");
        }
        /*增加cas通oauth的加密认证机制*/
        String password = "";
        try {
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
            // 用户信息
            Map<String, String> parameters = Maps.newHashMap();
            parameters.put("username", username);
            parameters.put("password", password);
            parameters.put("tenant_code", "0000");
            parameters.put("client_id", securityProperties.getAuth().getClientId());
            parameters.put("grant_type", "cas_server");
            parameters.put("client_secret", securityProperties.getAuth().getClientSecret());
            // 请求用户中心获取token
            ResponseEntity<OAuth2AccessToken> entity = authService.postAccessToken(token, parameters);
            if (entity == null) {
                return HttpResult.FAIL_EXCEPTION("账号或密码错误");
            }
            LoginUser loginBean = new LoginUser();
            loginBean.setUsername(username);
            OAuth2AccessToken auth2AccessToken = entity.getBody();
            // token 加入到缓存中
            loginBean.setToken(auth2AccessToken.getAccess_token());
            tokenService.setUserAgent(loginBean);
            tokenService.refreshToken(loginBean);
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, "登录成功!"));
            response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
            response.setHeader("Location", url + (url.contains("?") ? "&" : "?") + "token=" + auth2AccessToken.getAccess_token());
            response.setHeader("Set-Cookie", "token="+auth2AccessToken.getAccess_token()+"; path=/; expires="+attributes.get("expiredDate")+"; HttpOnly; ");
            return HttpResult.OK(auth2AccessToken);
        } catch (Exception e) {
//            log.error("cas登录回调失败:{}", e);
            if (e instanceof BadCredentialsException) {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, "账号或密码错误!"));
                return HttpResult.FAIL_EXCEPTION("账号或密码错误");
            } else {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                return HttpResult.FAIL_EXCEPTION("账号或密码错误");
            }
        }
    }

前后端分离模式,前端使用 window.location,跳转到/casLogin 接口带上 redirect 参数,注意 redirect 参数需要进行 encode 编码,代表登录完成之后需要回跳到那个地址,配置了 cas.validation-url-patterns 和 cas.authentication-url-patterns 两项配置之后,cas 会拦截这个接口,如果没有登录,会自动跳转到 cas 登录页面,登录完成之后,cas-serve 会回调这个接口,这个时候就可以获取到用户信息,并且做自己系统的鉴权。

2.3 统一登出

2.3.2 编写 Controller

@Value("${callback}")
private String callback;
@Value("${logout.service}")
private String service;
@Value("${cas.client-host-url}")
private String localService;
    @ApiOperation(value = "退出登录")
    @RequestMapping(value = "/casLogoutPre", method = RequestMethod.GET)
    public HttpResult casLogoutPre(HttpServletRequest request ,HttpServletResponse response,@RequestParam("token") String token,@RequestParam("redirect") String url) throws IOException {
        if(StringUtils.isEmpty(token) || UNDEFINED.equals(token)){
            response.sendRedirect(localService+"/admin/casLogin?redirect="+url);
            return HttpResult.OK();
        }
        DecodedJWT jwt = JWT.decode(token);
        Map<String, Claim> claims = jwt.getClaims();
        Claim exp = claims.get("exp");
        Date date = exp.asDate();
        String userName = claims.get("user_name").asString();
        if ((date != null && date.before(new Date()))) {
            HttpResult.FAIL_EXCEPTION("登陆状态已过期");
        }
        HttpSession session = request.getSession();
        session.invalidate();
        String redirect = callback+ URLEncoder.encode(service+"?username="+userName+"&redirect="+url,"utf-8");
        response.sendRedirect(redirect);
        return HttpResult.OK();
    }
    @ApiOperation(value = "退出登录")
    @RequestMapping(value = "/casLogoutAfter", method = RequestMethod.GET)
    public HttpResult casLogoutAfter(HttpServletRequest request,HttpServletResponse response,@RequestParam("username") String username,@RequestParam("redirect") String url) {
        SysUser user = sysUserService.selectUserByUserName(username);
        if (user != null) {
            tokenService.delToken(Constants.LOGIN_TOKEN_KEY + user.getUserName());
            // 记录用户退出日志
            logininforService.logout(user.getUserName());
        }
        HttpSession session = request.getSession();
        session.invalidate();
        CookieUtil.delCookie("SESSION");
        response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
        response.setHeader("Location",url);
        return HttpResult.OK();
    }

前端使用 window.location 跳转到/casLogoutPre,此时的参数里面包含 redirect,同样表示为退出登录之后跳转的地址,然后从/casLogoutPre 直接进行重定向,重定向到退出页面的地址"https://localhost:8888/cas/logout?service="+URLEncoder.encode("https://127.0.0.1:8080/casLogoutAfter?redirect="+url,"utf-8") service 之后拼接的参数为/casLogoutAfter 的地址,同时把 redirect 参数也带过来,在 casLogoutAfter 中进行重定向到退出之后的页面,同时也可以在这里把自己系统的鉴权信息清除。

特别注意:在登录的过程中是使用后端重定向到 cas-serve,所以在 cas-serve 中记录的为后端的地址,在退出的时候,也就需要后端去进行一次重定向,这样才能成功匹配登录的信息进行清除。

前端应用(代码)

  • vue3 版本,如 vue2 应用可按此逻辑修改

方法封装

  • utils/sso/index.js
//sso地址配置
import { ssoEnum, ssoDevEnum, ssoTestEnum } from '/@/enums/ssoEnum';
<!-- 判断环境 -->
import { isProdMode, isDevMode, isTestMode } from '/@/utils/env';
//用户获取token,根据项目不同按需修改
import { useUserStoreWithOut } from '/@/store/modules/user';

export function getSSOConf() {
  // 返回 SSO登录页地址
  try {
    if (isTestMode()) {
      return ssoTestEnum;
    } else if (isDevMode()) {
      return ssoDevEnum;
    } else if (isProdMode()) {
      return ssoEnum;
    } else {
      throw Error('未知环境');
    }
  } catch (error) {
    console.error(error);
  }
}

/**
 * @description 获取 SSO登录页地址
 * @returns string
 */
export function getSSOPageURL(redirect = window.location.href) {
  // 返回 SSO登录页地址
  const url = getSSOConf();
  const serviceEncoded = `${encodeURIComponent(redirect)}`;
  return `${url?.SSO_LOGIN}${serviceEncoded}`;
}

/**
  @description: 获取退出登录地址
  @return string 浏览器跳转URL地址
 */
export function getLogoutPageURL() {
  const url = getSSOConf();
  const userStore = useUserStoreWithOut(); // 用户信息
  const redirectEncoded = encodeURIComponent(window.location.href); // encode当前页面地址
  const token = userStore.getToken; // 用户token
  return `${url?.SSO_LOGOUT}?redirect=${redirectEncoded}&token=${token}`;
}

/**
 * @description 跳转到 SSO登录页
 * @returns void
 */
export function JumpToSSOPage(redirect?: string) {
  window.location.href = getSSOPageURL(getCurrentPageFullURL(redirect));
}

/**
  @description: 退出登录跳转
  @return void
 */
export function JumpToLogoutPage() {
  if (hasToken()) {
    // 退出登录 跳转
    window.location.href = getLogoutPageURL();
  } else {
    // 未登录 跳转到 SSO登录页
    JumpToSSOPage();
  }
}

/**
 * @description 检查是否存在token
 * @returns boolean
 */
export function hasToken() {
  const userStore = useUserStoreWithOut();
  const token = userStore.getToken;
  if (token) {
    return true;
  } else {
    return false;
  }
}

/**
 * @description 如果未登录 跳转到 SSO登录页 已登录则返回true
 * @returns boolean
 */
export function checkLogin(redirect?: string) {
  if (hasToken()) {
    return true;
  } else {
    JumpToSSOPage(redirect);
    return false;
  }
}

/**
 * @description 返回当前页面 完整url
 * @returns string
 */
export function getCurrentPageFullURL(path?: string) {
  if (path && !path.includes('http')) {
    return `${window.location.origin}${path}`;
  } else {
    return window.location.href;
  }
}

  • ssoEnum.ts 登录页配置
export enum ssoEnum {
  // 生产环境 登录地址
  SSO_LOGIN = '',
  // 生产环境 登录地址
  SSO_LOGOUT = '',
}

export enum ssoDevEnum {
  // 开发环境 service 参数
  SSO_LOGIN = 'http://192.168.5.12:8080/admin/casLogin?redirect=',
  // 开发环境 退出登录 url ?redirect=&token=
  SSO_LOGOUT = 'http://192.168.5.12:8080/admin/casLogoutPre',
}

export enum ssoTestEnum {
  // 测试环境 service 参数
  SSO_LOGIN = 'https://admin.uni-links.com.cn/admin/casLogin?redirect=',
  // 测试环境 退出登录 url ?redirect=&token=
  SSO_LOGOUT = 'https://admin.uni-links.com.cn/admin/casLogoutPre',
}

  • utils/env
/**
 * @description: Is it a development mode
 * @returns:
 * @example:
 */
export function isDevMode(): boolean {
  return import.meta.env.DEV;
}

/**
 * @description: Is it a production mode
 * @returns:
 * @example:
 */
export function isProdMode(): boolean {
  return import.meta.env.PROD;
}

/**
 * @description: Is it a test mode
 * @returns:
 * @example:
 */
export function isTestMode(): boolean {
  return import.meta.env.VITE_MODE_NAME === "test";
}

使用(前端)

 import { JumpToSSOPage, getCurrentPageFullURL } from '/@/utils/sso';
 router.beforeEach(async (to, from, next) => {
 ...
    if (to.query.token) {
      const query = to.query;
      const queryWithOutToken = Object.keys(query).reduce((acc, cur) => {
        if (cur !== 'token') {
          acc[cur] = query[cur];
        }
        return acc;
      }, {} as Recordable);
      userStore.setToken(to.query.token as string);
      if (to.query.redirect) {
        const redirectPath = to.query.redirect as string;
        next({ path: redirectPath, query: queryWithOutToken });
        return;
      } else {
        // next({ path: ROOT_PATH });
        next({ path: to.path, query: queryWithOutToken });
        return;
      }
    }
 ...

 if (!token) {
  ...
      next(false);
      JumpToSSOPage(getCurrentPageFullURL(to.fullPath));
    ...
 }
 })

  • 退出登录方法
 import { JumpToLogoutPage } from '/@/utils/sso';
 async logout() {
  // 跳转SSO 退出登录页
  JumpToLogoutPage();
  ....
 }