背景及目的
之前做一个公司项目的时候甲方要求集成他们指定的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即可。
实现过程
前期准备
- cas认证服务端部署
- 注册CAS协议的应用,需要配置service/callbackUrl/clientName
- vue-admin-template模板项目
- 简单的springboot项目
- 配置几个用于跳转的路由,在路由文件中添加
{
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就不在此列举了。
测试过程
- 启动前后端及认证服务器项目
- 访问前端地址 会跳转到认证服务器定义的loginUrl
- 登录完成后会调用http://localhost:9528/#/callback,被路由守卫拦截后进行token的获取和保存
- 保存完成后进入首页
总结
本文主要记录了前后端分离场景下集成CAS单点登录的基本流程。
作为记录的同时也希望能帮助到需要的朋友,有任何疑问欢迎留言评论。
创作不易,欢迎一键三连~