一、简介
目前,许多高校的微服务体系都采用基于CAS认证的方式。在CAS认证机制下,CAS服务器和后端接口可能运行在不同的域名或端口上。然而,主流的前后端分离架构中,前端通常运行在独立的端口上。由于浏览器的同源策略的限制,跨域请求可能会受到限制,因此需要进行额外的配置和处理来解决跨域问题。如果前后端不分离,前端的开发将受到较大的制约。为了兼顾开发的效率以及又不对之前的微服务产生影响,得出了以下方案。
二、项目环境
- 前端:Vue+Axios+VueRouter
- 后端:Nginx+Catalyst+Docker
三、前后端不分离的CAS认证流程
- Browser发起请求到后端
- Backend检测到未登录301重定向Browser到CAS SERVER
- Browser被重定向到CAS SERVER并进行登录
- CAS SERVER认证成功后发放ticket到Browser
- Browser拿到ticket向Backend重新发起请求
- CAS client检测到ticket向CAS server校验ticket合法性
- CAS server对ticket校验校验后返回校验信息
- CAS client验票成功后发放cookie到Browser
完整流程图如下:
详细流程如下:
-
当用户请求应用时,应用检测到用户未登录,将用户重定向到CAS服务器的登录页面。
- 资源的请求url
- 检测到状态未登录重定向的url,service参数为加密后的原本应用的url
-
当用户点击登录后,CAS服务器验证用户的凭证(用户名和密码)。如果凭证有效,CAS服务器会颁发一个唯一的票据(Ticket)给用户,并重定向回应用页面。
-
验证通过后,CAS服务器将用户信息存入Cookie(或使用Token),并保存在用户的浏览器中。之后的每次请求都会携带Cookie。
四、前后端分离的CAS认证流程
- Browser向Static server发起请求
- Static serveri返回html给Browser
- Browser html判断是否认证,如果没有则跳转CAS server
- 跳转CAS server,此时的callback地址指向API server的CAS client(或自定义CAS验票函数)
- 登录成功CAS serveri跳转到API Backend并携带ticket
- 向CAS server发起验票
- 返回验票结果,如果验票通过跳转到用户发起请求的地址,并设置cooke
- Browser拿到cookie可以正常发起API调用请求
完整流程图如下:
1、CAS认证前端逻辑
- 前端APP的入口,检测localStorage中是否含有某个flag, 这里我们设置了一个名为authDate的key,value为认证的时间
- 当没有检测到authDate的时候,跳转前端配置的CAS server地址,并将CAS server的回调地址改写为接口后端的认证路由,这里我们在接口后端编写了一个名为serviceValidate的GET调用的方法
一级跳转:CAS回调地址
http://cas-server/authserver?service=http://backend-api/serviceValidate?service=http://vue-project
二级跳转:backend的serviceValidate 回调地址
http://backend-api/serviceValidate?service=http://vue-project
- 到CAS server认证成功后,会发放一个ticket添加到二级跳转后面,所以当CAS server重定向会浏览器时并跳转http://backend-api/serviceValidate的时候会带有两个参数,一个是service和ticket,其中service用于验票成功后跳转回前端的地址,ticket是CAS server发放的票据。
2、CAS认证后端逻辑(serviceValidate接口的逻辑)
- 当检测到ticket时,利用CAS client向CAS server进行验票或者自己通过CAS协议去进行验票,当验票通过,CAS server将会返回票据所属的用户信息
- 拿到用户信息后,设置cookie给当前调用域,届时前端调用接口的时候走cookie调用
- 根据service的地址,进行重定向返回到service
说明:
因为CAS server一级返回的地址会让浏览器重新重定向到接口服务的serviceValidate方法,所以浏览器在认证过程中可以得到接口的cookie,这个的且整个过程中不需要使用JS操作cookie,同时cookie也能设置且应该设置httponly
在这个过程中CAS server应该设置ticket只能使用一次, 防止ticket泄漏后可被多次使用
五、核心实现
1、前端相关配置
- 入口文件main.js
在页面挂载前,先进行CAS认证,如果没有登录过,先挂载loading组件,如果通过则返回应用。
import Vue from 'vue'
import App from './App.vue'
import CASAuth from "./CasClient";
import Loading from './components/Loading.vue'
Vue.config.productionTip = false
if ( CASAuth(window.location.href ,true)) {
new Vue({
render: h => h(App),
}).$mount('#app<img src="')" width="70%" />
}
else{
new Vue({
render: h => h(Loading),
}).$mount('#app')
}
- 前端CAS认证流程如下图所示
- CASAuth.js
import config from "./config" // 导入配置文件
function getUrlParams(url, slash = false) {
let service_urlStr = url.split('?')
let currentURL = service_urlStr[0].split('/');
let obj = {"service": currentURL[0] + '//' + currentURL[2] + (slash ? '/' : '')};
let urlStr = service_urlStr[1]
if (!urlStr) {
return obj
}
let paramsArr = urlStr.split('&')
for (let i = 0, len = paramsArr.length; i < len; i++) {
let arr = paramsArr[i].split('=')
obj[arr[0]] = arr[1];
}
return obj
}
async function CASAuth(url, service_slash = false){
let UrlPara = getUrlParams(url, service_slash);
if (window.localStorage.getItem('authDate') !== null ){
return true
}
let app_url=config.front_url
let second_valid_url = `${config.backend_cas_valid_url}/serviceValidate?service=${app_url}`
if ( ! Object.prototype.hasOwnProperty.call(UrlPara, 'ticket') ) {
window.location.href = `${config.cas_server}?service=${second_valid_url}`;
return false
}
if(Object.prototype.hasOwnProperty.call(UrlPara, 'ticket')){
window.localStorage.setItem('authDate', new Date())
window.location.href = window.location.href.split('?')[0]
return true
}
}
export async function RECASAuth(url, service_slash = false){ // 接口调用时如果检测到认证过期或者认证跳转时候调用
let UrlPara = getUrlParams(url, service_slash);
window.localStorage.removeItem('authDate');
let app_url=config.front_url
let second_valid_url = `${config.backend_cas_valid_url}/serviceValidate?service=${app_url}`
if ( ! Object.prototype.hasOwnProperty.call(UrlPara, 'ticket') ) {
window.location.href = `${config.cas_server}?service=${second_valid_url}`;
}
}
export default CASAuth;
参数说明:
cas_server
:单点登录地址
backend_cas_valid_url
:nginx反代的后端接口验证地址
app_url
:nginx反代下的前端应用地址
- config.js
let config = {
"cas_server": "http://cas-server", // CAS服务器地址
"front_url":"http://vue-project", // 前端应用地址
"backend_cas_valid_url": "http://backend-api", // 后端接口地址
}
export default config
- 请求拦截和响应拦截
import axios from 'axios'
import {RECASAuth} from '@/CasClient'
import { notification } from 'ant-design-vue';
import config from "../config";
const service = axios.create({
baseURL: config.backend_cas_valid_url
})
// axios请求拦截
service.interceptors.request.use(function (config) {
config.headers['Accept']='application/json'
return config
})
// axios 响应拦截
service.interceptors.response.use(async response => {
return response
}, async err => {
if (err.response.status==401){
RECASAuth(window.location.href ,true)
}
else if (err.response.status==502){
notification.error({
message: '服务器内部错误',
description: error.response.data.message,
});
}
else {
// 对其他错误进行处理
notification.error({
message: '网络错误',
description: '请检查网络连接',
});
}
return err
}
)
// promise接口调用
export default service
2、后端校验服务源代码(Catalyst perl)
=head2 serviceValidate
后端验票并发放cookie
=cut
sub serviceValidate : Local {
my ( $self, $c ) = @_;
unless ( $c->authenticate({'service'=>$service_url})) {
$c->stash->{RNT_CODE} = "401";
$c->stash->{RTN_MSG} = '认证失败';
$c->forward("View::JSON");
return $c->detach();
}
my $service = $c->req->parameters->{service} || undef;
if (!defined($service)){
$c->stash->{RNT_CODE} = "403";
$c->stash->{RTN_MSG} = 'service不存在,服务未知';
$c->forward("View::JSON");
return $c->detach();
}
$c->res->status(301);
$c->log->debug( '重定向到前端服务 "'.$service )
if $c->debug;
return $c->res->redirect( $service.'?ticket='.$c->req->parameters->{ticket} );
}