【工作记录】前后端分离场景下CAS单点登录的集成思路与实践@20230926

背景及目的

之前做一个公司项目的时候甲方要求集成他们指定的CAS服务端实现登录,要求不影响原有业务。
CAS服务端提供的文档都是基于前后端不分离的应用,对前后端分离应用没有任何说明,找官方人问也是爱答不理的,近期正好有时间就想着研究下这个集成过程。
于是有了这篇文章,主要为了记录下集成过程和相关配置,方便后续类似的对接。
当然也希望能帮助到需要的朋友。

CAS涉及到的角色

  • 认证服务器
  • 客户端-API
  • 客户端-前台

整体流程梳理:

  • 客户端请求地址A
  • 发现用户没有token,跳转到指定登录页面,地址类似/cas/login?service=xxxxx
  • 请求成功后会回调callbackUrl同时拼接ticket参数,
  • 客户端发起ticket验证请求,v2: /serviceValidate v3: /p3/serviceValidate
  • 验证成功后会回调到callbackUrl中,同时在session中添加指定用户属性
  • 生成token并传递到前端或前端通过请求拿到token
    • 思路一: 客户端拿到用户属性后根据用户生成token返回给前端,可以通过指定url拼接参数给到前端,前端拦截后保存
    • 思路二: 客户端拿到用户属性后重定向到一个约定的前端地址,前端在路由守卫中拦截该路径并在拦截到后发起getToken请求,请求成功后
      保存token等信息到缓存或者cookie即可。

实现过程

前期准备

  1. cas认证服务端部署
  2. 注册CAS协议的应用,需要配置service/callbackUrl/clientName
  3. vue-admin-template模板项目
  4. 简单的springboot项目
  5. 配置几个用于跳转的路由,在路由文件中添加
  {
    path: '/callback',
    hidden: true
  },
  {
    path: '/tologin',
    hidden: true
  },

这里只是为了路由守卫的拦截,可以不写vue页面,只需要在路由列表中添加定义即可,亲测无误。

改造过程-前端

路由守卫文件 src/permission.js
import {getToken} from '@/utils/auth'; // get token from cookie
import getPageTitle from '@/utils/get-page-title'
import NProgress from 'nprogress'; // progress bar
import 'nprogress/nprogress.css'; // progress bar style
import router from './router'
import store from './store'

NProgress.configure({ showSpinner: false }) // NProgress Configuration

const whiteList = ['/login'] // no redirect whitelist

//是否支持cas登录的开关
const enableCasLogin = true

router.beforeEach(async(to, from, next) => {
  // start progress bar
  NProgress.start()

  // set page title
  document.title = getPageTitle(to.meta.title)
  const hasToken = getToken()
  //判断是否是去login页面
  if(to.path === '/tologin') {
     if(enableCasLogin){
       //实际访问的cas登录地址
       window.location.href = `http://localhost:9009/cas/login?service=http://localhost:8989/test1/index&redirect=${to.params.redirect}`
     } else {
       next(`/login`)
     }
     NProgress.done()
  } else if (whiteList.indexOf(to.path) !== -1) {
     next();
  } else if (to.path === '/callback') {
     if(!hasToken) {
       await store.dispatch(`user/resetToken`)
     }
     next('/')
     NProgress.done()
  } else {
     if(hasToken) {
       next()
     } else {
       if(to.path === '/') {
         next(`/tologin`)
       } else {
         next(`/tologin?redirect=${to.path}`)
       }
     }
     NProgress.done()
  }
})

router.afterEach(() => {
  // finish progress bar
  NProgress.done()
})
接口配置 /api/user.js
import request from '@/utils/request'

export function login(data) {
  return request({
    url: '/auth/login',
    method: 'post',
    data
  })
}

export function getCaptcha() {
  return request({
    url: '/auth/captcha',
    method: 'get',
  })
}

export function getCasToken() {
  return request({
    url: '/auth/getToken',
    method: 'get',
  })
}

export function logout() {
  return request({
    url: '/vue-admin-template/user/logout',
    method: 'post'
  })
}
store配置 src/store/modules/user.js
import { getCasToken, login, logout } from '@/api/user'
import { resetRouter } from '@/router'
import { getToken, removeToken, setToken } from '@/utils/auth'

const getDefaultState = () => {
  return {
    token: getToken(),
    name: '',
    avatar: ''
  }
}

const state = getDefaultState()

const mutations = {
  RESET_STATE: (state) => {
    Object.assign(state, getDefaultState())
  },
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  SET_NAME: (state, name) => {
    state.name = name
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  }
}

const actions = {
  // user login
  login({ commit }, userInfo) {
    return new Promise((resolve, reject) => {
      login(userInfo).then(response => {
        const { data } = response
        commit('SET_TOKEN', data.tokenType + " " + data.token)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

  // user logout
  logout({ commit, state }) {
    return new Promise((resolve, reject) => {
      logout(state.token).then(() => {
        removeToken() // must remove  token  first
        resetRouter()
        commit('RESET_STATE')
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

  // remove token
  resetToken({ commit }) {
    return new Promise(resolve => {
      removeToken() // must remove  token  first
      commit('RESET_STATE')
      getCasToken().then(response => {
        const { data } = response
        commit('SET_TOKEN', data.tokenType + " " + data.token)
        commit('SET_NAME', data.user.username)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}
配置下接口地址 src/vue.config.js
module.exports = {
  /**
   * You will need to set publicPath if you plan to deploy your site under a sub path,
   * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/,
   * then publicPath should be set to "/bar/".
   * In most cases please use '/' !!!
   * Detail: https://cli.vuejs.org/config/#publicpath
   */
  publicPath: '/',
  outputDir: 'dist',
  assetsDir: 'static',
  lintOnSave: process.env.NODE_ENV === 'development',
  productionSourceMap: false,
  devServer: {
    port: port,
    open: true,
    overlay: {
      warnings: false,
      errors: true
    },
    // before: require('./mock/mock-server.js')
    proxy: {
      '/dev-api': {
        target: 'http://localhost:8989',
        pathRewrite: { '^/dev-api': '' }
      }
    }
  },
  configureWebpack: {
    // provide the app's title in webpack's name field, so that
    // it can be accessed in index.html to inject the correct title.
    name: name,
    resolve: {
      alias: {
        '@': resolve('src')
      }
    }
  },
  chainWebpack(config) {
    // it can improve the speed of the first screen, it is recommended to turn on preload
    config.plugin('preload').tap(() => [
      {
        rel: 'preload',
        // to ignore runtime.js
        // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
        fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
        include: 'initial'
      }
    ])

    // when there are many pages, it will cause too many meaningless requests
    config.plugins.delete('prefetch')

    // set svg-sprite-loader
    config.module
      .rule('svg')
      .exclude.add(resolve('src/icons'))
      .end()
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/icons'))
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
      .end()

    config
      .when(process.env.NODE_ENV !== 'development',
        config => {
          config
            .plugin('ScriptExtHtmlWebpackPlugin')
            .after('html')
            .use('script-ext-html-webpack-plugin', [{
            // `runtime` must same as runtimeChunk name. default is `runtime`
              inline: /runtime\..*\.js$/
            }])
            .end()
          config
            .optimization.splitChunks({
              chunks: 'all',
              cacheGroups: {
                libs: {
                  name: 'chunk-libs',
                  test: /[\\/]node_modules[\\/]/,
                  priority: 10,
                  chunks: 'initial' // only package third parties that are initially dependent
                },
                elementUI: {
                  name: 'chunk-elementUI', // split elementUI into a single package
                  priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
                },
                commons: {
                  name: 'chunk-commons',
                  test: resolve('src/components'), // can customize your rules
                  minChunks: 3, //  minimum common number
                  priority: 5,
                  reuseExistingChunk: true
                }
              }
            })
          // https:// webpack.js.org/configuration/optimization/#optimizationruntimechunk
          config.optimization.runtimeChunk('single')
        }
      )
  }
}

改造过程-后端

添加依赖 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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.13</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.zjtx.tech</groupId>
    <artifactId>auth-cas-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>auth-cas-api</name>
    <description>CAS API</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>net.unicon.cas</groupId>
            <artifactId>cas-client-autoconfig-support</artifactId>
            <version>2.3.0-GA</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

依赖中主要添加的是cas-client-autoconfig-support这个jar包,提供了cas需要的一些配置和拦截器

配置文件添加
server:
  port: 8989
cas:
  # 配置实际的cas地址
  server-url-prefix: http://localhost:9009/cas
  # 配置实际的cas登录地址
  server-login-url: http://localhost:9009/cas/login
  client-host-url: http://localhost:8989/
  # 这里可以选择cas 和 cas3 区别是请求的部分地址不一样,如ticket验证的接口
  validation-type: cas
#  拦截的URL地址
  authentication-url-patterns:
    - /*
spring:
  jackson:
    serialization:
      FAIL_ON_EMPTY_BEANS: false
获取token的controller

src/main/java/com/zjtx/tech/controller/AuthController.java

package com.zjtx.tech.contorller;

import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/auth")
public class AuthController {

    @GetMapping("getToken")
    public ResultBean<TokenUser> getToken(HttpServletRequest request){
        Assertion assertion = (Assertion) request.getSession().getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
        System.out.println("assertion = " + assertion.getPrincipal().getAttributes());
        String username = assertion.getPrincipal().getName();
        System.out.println(username);
        //这里仅为了演示直接new了一个简单对象返回给前端
        SimpleUserBean user = new SimpleUserBean("1", username, "123456", "456789");
        return new ResultBean<>(200, "success", new TokenUser(user, "123456", "Bearer"));
    }

}
回调接口对应的controller

src/main/java/com/zjtx/tech/controller/TestController.java

package com.zjtx.tech.contorller;

import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RestController
public class TestController {

    @GetMapping("test1/index")
    public void index(HttpServletRequest request, HttpServletResponse resp) throws IOException {
        String token = request.getParameter("congress");
        System.out.println("congress : " + token);
        Assertion assertion = (Assertion) request.getSession().getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
        System.out.println("assertion = " + assertion.getPrincipal().getAttributes());
        String username = assertion.getPrincipal().getName();
        System.out.println(username);

        resp.sendRedirect("http://localhost:9528/#/callback");
    }

    @GetMapping("test1/index1")
    public String index1(HttpServletRequest request) {
        String token = request.getParameter("token");
        System.out.println("token : " + token);
        Assertion assertion = (Assertion) request.getSession().getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
        String username = assertion.getPrincipal().getName();
        System.out.println(username);

        return "test index cas拦截正常,登录账号:" + username;
    }

    /**
     * 不走cas认证,无法获取登录信息
     *
     * @param request
     * @return
     */
    @GetMapping("test1/index2")
    public String index2(HttpServletRequest request) {
        return "cas 未拦截";
    }
}

涉及到的简单bean就不在此列举了。

测试过程

  1. 启动前后端及认证服务器项目
  2. 访问前端地址 会跳转到认证服务器定义的loginUrl
  3. 登录完成后会调用http://localhost:9528/#/callback,被路由守卫拦截后进行token的获取和保存
  4. 保存完成后进入首页

总结

本文主要记录了前后端分离场景下集成CAS单点登录的基本流程。
作为记录的同时也希望能帮助到需要的朋友,有任何疑问欢迎留言评论。
创作不易,欢迎一键三连~

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

泽济天下

你的鼓励是我最大的动力。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值