基于SSO单点登录开发的认证平台。实现了多个独立的业务系统之间的访问控制,用户只需要登录一次,就可以访问所有已经集成单点登录功能的业务系统,不需要对每一个业务系统都逐一登录。
单点登录概述
概念
单点登录(Single Sign On),简称SSO。是指在多个应用系统中,用户只需要登录一次,就可以访问所有相互信任的应用系统。
方案介绍
1. 技术选型:
采用OAuto2.0授权协议实现单点登录功能。本系统使用 Authorization Code(授权码模式)来获取 访问令牌 (access_token)
流程:
2. 涉及系统(只包含前端):
系统 | 说明 |
---|---|
统一身份认证平台 | 用于业务系统登录,身份认证 |
管理后台 | 对集成单点登录的业务系统进行管理,包含业务系统注册、修改、注销等功能 |
业务系统 | 自身需要集成单点登录,可涉及多个业务系统 |
统一身份认证平台
平台包含功能:登录、注册、账号管理
需要开发一个独立的身份认证系统,其余业务系统不提供登录入口 (如果业务系统需要保留自身登录入口,可以在自身登录页集成单点登录模式,让用户可以根据需求选择使用哪种登录方式)。
业务系统在进行单点登录时,会统一跳转到身份认证系统的登录页。登录成功后通过重定向跳转回业务系统 的认证页面
注意:此项目前后端必须部署在同一个ip下。
- 后端拦截器会根据cookie中是否存在用户信息,来进行跳转
- cookie则无法跨域携带,如果登录系统与后端不在同一个ip下,会产生跨域问题。
- 也可以尝试解决跨域问题,试过网上的配置没找到合适方法,最简单的方法就是把前后端部署在同一个ip下。
管理后台
使用开源框架 maxkey,对其进行了二次开发。后台管理使用了maxkey-web-frontend/maxkey-web-mgt-app 路径下的代码。
github地址:https://github.com/dromara/MaxKey.git
框架参考地址:http://ng.ant.design/docs/introduce/zh
可以通过 git upstream 将 maxkey源代码与自己仓库关联,maxkey项目更新了方便同步代码。因为前端只用到了 maxkey-web-mgt-app 路径下代码,通过稀疏检出屏蔽多余代码
maxKey前端maxkey-web-mgt-app项目拆分:
- 第一种:git clone只能整个项目,不能指定目录
- 第二种采用稀疏检出:
- 添加上游远程:git remote add upstream https://gitee.com/dromara/MaxKey
- 开启稀疏检出:git config core.sparsecheckout true
- 配置稀疏检出文件 :echo "/maxkey-web-frontend/maxkey-web-mgt-app/* " >> .git/info/sparse-checkout
- 拉取远端代码: git pull upstream main
- 注意:稀疏检出后可以只拉取maxkey-web-mgt-app目录下的文件,但是保留了Maxkey项目的目录结构(maxkey-web-frontend/maxkey-web-mgt-app)
maxkey-web-mgt-app使用angular语法开发,简单记录一下项目运行打包流程
maxkey-web-mgt-app项目调试运行:
- 1.全局安装npm install -g @angular/cli
- 2.初始化项目 cnpm install
- 3.ng serve
api请求地址baseUrl修改:environment.prod.ts (生产、测试环境) | environment.ts(开发环境)
项目打包 :ng build(打包前务必确认environment.prod.ts 中baseUrl环境是否对应)
打包后目录前缀修改: angular.json中baseHref修改dist文件部署在服务器上的目录前缀
业务系统
业务系统需要对自身的登录、退出功能进行改造。
代码实现
统一身份认证平台
1. 登录页面:/user/login
用户登录成功后,将返回的congress 和 online_ticket 存入cookie
// 登录
Login({
commit }, userInfo) {
userInfo.authType = 'bx-mobile'
userInfo.state = storage.get('loginState')
userInfo.remeberMe = false
return new Promise((resolve, reject) => {
loginByMobile(userInfo).then(response => {
if (response.code == 0) {
storage.set('token', response.data.token)
storage.set('userId', response.data.userId)
storage.set('userInfo', response.data)
commit('SET_TOKEN', response.data.token)
commit('SET_USERID', response.data.userId)
commit('SET_USERINFO', response.data)
VueCookies.set('congress', response.data.token, {
path: '/' })
VueCookies.set('online_ticket', response.data.ticket, {
domain: getSubHostName(), path: '/' })
}
resolve(response)
}).catch(error => {
reject(error)
})
})
}
根据浏览器queryString中是否携带参数redirect_uri进行判断:
- 携带: window.open(redirect_uri, ‘_self’),通过重定向跳转到统一身份认证平台授权页面(拦截器中会对cookie进行校验,失败则不会跳转)
- 不携带:this.$router.push(‘/home’),跳转平台首页
// 登录
submitForm() {
this.$refs[this.current].validate(async valid => {
if (!valid) return
let params = {
...this[this.current] }
// 账号登录 密码加密
if (this.current == 'accountForm') {
params.password = smcrypto(params.password)
}
this.Login(params).then(({
code, data, message }) => {
if (code != 0) {
this.$message.error(message, 2)
this.getCaptcha()
return
}
// 缓存用户名和手机号
if (this.current === 'accountForm' && this.rememberUsername) {
storage.set('rememberUsername', this.accountForm.username)
}
if (this.current === 'codeForm' && this.rememberMobile) {
storage.set('rememberMobile', this.codeForm.mobile)
}
if (this.$route.query.redirect_uri) {
let redirect_uri = CryptoJS.enc.Base64url.parse(this.$route.query.redirect_uri).toString(CryptoJS.enc.Utf8)
window.open(redirect_uri, '_self')
} else {
this.$router.push('/home')
}
})
})
}
2. 平台首页:/home
对已授权的第三方业务系统进行管理。用户可以通过在身份认证系统登录,从首页跳转到选定的业务系统
// 跳转其他业务系统
gotoOtherSystem(item) {
if(item.protocol === 'Basic' || item.inducer === 'SP') {
window.open(item.loginUrl)
} else {
console.log(`${
this.$store.state.baseUrl}/sign/authz/${
item.id}`)
window.open(`${
this.$store.state.baseUrl}/sign/authz/${
item.id}`)
}
}
3. 授权页面:/authorize
身份认证平台用户向用户请求授权, 用户是否允许第三方业务系统获得用户个人信息
分为 手动授权 和 自动授权两种模式。手动授权需要用户手动操作,自动授权对用户来说是无感知的
// 证实批准
approvalConfirm() {
approvalConfirm(this.$route.query.oauth_approval).then(({
code, data, message }) => {
if (code != 0) {
this.$message.error(message, 2)
return
}
console.log('get-form表单数据', data)
this.form.clientId = data.clientId
this.form.appName = data.appName
this.form.iconBase64 = data.iconBase64
this.form.oauth_version = data.oauth_version
this.form.user_oauth_approval = 'true'
this.form.approval_prompt = data.approval_prompt
// 授权流程为自动时, 自动触发
if (this.form.approval_prompt == 'auto') {
this.approvalAuthorize()
}
})
},
// 批准授权
approvalAuthorize() {
approvalAuthorize(this.form).then(({
code, data, message }) => {
if (code != 0) {
this.$message.error('提交失败', 2)
return
}
if(this.form.approval_prompt == 'force') {
this.$message.success('提交成功', 2)
}
// 跳转
window.location.href = data
})
},
// 拒绝授权
onDeny() {
window.close()
}
4. 退出页面:/user/logout
退出时需要清除cookie中缓存的congress、online_ticket信息
Logout({
commit, state }) {
return new Promise((resolve) => {
commit('SET_TOKEN', undefined)
commit('SET_USERID', undefined)
commit('SET_USERINFO', {
})
storage.remove('token')
storage.remove('userId')
storage.remove('userInfo')
let rememberUsername = storage.get('rememberUsername')
let rememberMobile = storage.get('rememberMobile')
// 清除所有的键
storage.clearAll()
storage.set('rememberUsername', rememberUsername)
storage.set('rememberMobile', rememberMobile)
// 清除cookie
VueCookies.remove('congress')
VueCookies.remove('online_ticket')
resolve(true)
})
}
根据浏览器queryString中是否携带参数redirect_uri进行判断:
- 携带:window.open(redirect_uri, ‘_self’)打开,会将页面重定向到单统一身份认证平台的登录页,并携带参数redirect_uri
- 不携带:this.$router.push(‘/user/login’),跳转统一身份认证平台登录页
logout().then(({
code, data, message }) => {
if (code != 0) {
this.$message.error(message || '退出登录失败', 2)
return
}
this.Logout().then(response => {
if (process.env.NODE_ENV === 'development' || !this.redirect_uri) {
this.$router.push('/user/login')
} else {
window.open(this.redirect_uri, '_self')
}
})
})
管理后台
1. 修改项目请求地址baseUrl。项目打包部署 ng bulid
environment.prod.ts
export const environment = {
production: true,
useHash: true,
api: {
baseUrl: 'http://10.110.208.213:9526/maxkey-mgt-api/', // 生产环境
// baseUrl: 'http://192.168.202.55:9526/maxkey-mgt-api/', // 测试环境
refreshTokenEnabled: true,
refreshTokenType: 're-request'
}
} as Environment;
2. 应用管理中 对 业务系统注册 单点登录功能。同时也可以对一些配置项进行修改
业务系统需要以下资源:
资源名称 | 说明 |
---|---|
登录地址 | 业务系统的登录页地址,业务系统可以创建一个用于单点登录的授权页地址作为登录地址,来区分单点登录和普通登录。 |
认证地址 | 业务系统用于授权单点登录的地址,可以和提供的登录地址相同。 |
应用名称 | 应用名称是指业务系统的全称,例如:测试业务系统。 |
在应用配置中填写完上述信息,修改完配置项后。可以得到以下资源
资源名称 | 说明 |
---|---|
client_id | 应用编码,即客户端id |
client_secret | 应用秘钥,即客户端秘钥 |
业务系统集成单点登录前端配置
1. 增加单点登录入口
业务系统登录页面,增加入口,点击跳转授权页面进行单点登录
this.$router.push('/idass')
2. 授权&单点登录页面:/idass
新增idass授权页面,并添加/idass路由,对该路由设置访问白名单权限
路由跳转前添加拦截,如果路由中没有携带code,window.open打开授权重定向接口,跳转身份认证平台登录页面
beforeRouteEnter(to, from, next) {
if (!to.query.code) {
window.open(`${
vuex.state.baseUrl}/api/sso/directAuthorize`, '_self')
} else {
next()
}
},
created方法中书写以下逻辑,如果路由中携带code,调用单点登录接口 /api/sso/ssoLogin 获取用户信息,并缓存;
接口调用成功后,执行原本登录成功后代码逻辑
created() {
this.Login({
userInfo: {
code: this.$route.query.code },
_this: this
})
}
// 登录
Login({
commit }, DATA) {
let callback = response => {
... 原本登录后逻辑
}
let {
userInfo, _this } = DATA
let loginType = userInfo.code ? 'idass' : userInfo.account ? 'account' : 'code'
switch (loginType) {
case 'idass': // 单点登录
ssoLoginUsingGET(userInfo).then(response => {
if (response.code === 200) storage.set('idass', true)
callback(response)
})
break
case 'code': // 账号
userInfo.code = undefined
userInfo.encryptedData = undefined
userInfo.iv = undefined
userInfo.type = 'VERIFICATION'
loginByMobileUsingPOST(userInfo).then(response => callback(response))
break
case 'account': // 验证码
loginByPwdUsingPOST(userInfo).then(response => callback(response))
break
}
},
3. 单点退出功能集成
如果是单点登录,退出时需要调用window.open(
${vuex.state.baseUrl}/api/sso/logout
, ‘_self’)打开单点退出重定向方法
- 该方法会重定向到统一身份认证平台退出页面,并携带有redirect_uri
- 退出后,会重定向到统一身份认证平台登录页,并携带有redirect_uri
- 用户在当前页面再次登录,因为querystring中有参数redirect_uri,所以会再次走授权流程
// 登出
Logout({
commit, state }) {
return new Promise((resolve) => {
if (storage.get('idass')) {
// 清除所有的键
storage.clearAll()
window.open(`${
vuex.state.baseUrl}/api/sso/logout`, '_self')
} else {
// 清除所有的键
storage.clearAll()
router.push('/user/login')
resolve('200')
}
})
}