​ 基于SpringBoot-Vue的前后端分离项目,服务器部署在CentOS8操作系统上,与第三方进行统一认证接口的对接。第三方统一认证登录可以解决用户在浏览器打开多个web应用时需要输入多次用户名密码登录的问题,实现用户一次登录,多个系统使用的效果。

Web登录过程

​ Web页面集成统一认证的原理是:当在浏览器打开集成了统一认证的某应用的页面时,页面重定向跳转到统一认证登录界面,登录之后页面重新回到用户请求的目标页面,而此时,如果用户在相同浏览器再打开另外一个集成了统一认证的页面时,由于该页面也会跳转到统一认证登录页,因为是在相同浏览器,统一认证登录的session仍然有效,所以会自动验证用户已经登录,不需要重新登录,便可以自动跳转到用户访问真正的目标页面,实现单点登录。

​ web应用接入的登录流程如下:

1635153653761

  1. 当用户首次访问Web应用时,Web应用会将请求重定向到统一认证的登录页面,重定向的过程一般是重定向到登录接口,这个过程重定向需要传三个参数:
    • client_id
    • response_type
    • redirect_uri
  2. 用户在登录页面输入用户名密码后提交登录,这个过程与Web应用无关,由统一认证服务管控。
  3. 统一认证服务会颁发授权码(code)和id_token给Web应用,并将用户重定向到Web应用。
  4. Web应用通过id_token验证用户身份,完成本地登录。验证id_token之后,从id_token的playload中即可获取用户信息
登录接口
URLhttp://serverIp:port/serverPath/oauth2/authorize
调用方式GET
参数redirect_uri:重定向地址 response_type:重定向的时候需要附加的用户登录信息,可选code或code id_token client_id:应用唯一标识
返回值登录页面
属性说明用户通过登录页面输入用户名和密码登录成功后,统一认证服务会将授权码和ID Token作为参数附加在redirect_uri后面并重定向到这个地址。 客户端可以通过ID Token获取用户信息,完成本地登录。
备注ID Token是一个使用HS256算法和ClientSecret作为密钥签名的JWT,客户端必须使用自己的ClientSecret验证ID Token的有效性。
前端页面跳转

​ 项目使用的是RUOYI框架

​ 当进入登录页面时,使用vue-router路由守卫进行拦截,判断cookie里是否存在本地token,不存在或者token已过期则跳转到第三方登录界面进行登录。

​ 修改permission.js文件

1635215125620

// 路由守卫:主要用来通过跳转或取消的方式守卫路由
router.beforeEach((to, from, next) => {
  NProgress.start() // 开启进度条
  document.title = defSet.title
  // 跳转到index携参token
  if (to.path.indexOf('/index') !== -1) {
    console.log("token设置");
    if (to.query.localToken !== null && to.query.localToken !== undefined) {
      setToken(to.query.localToken)
    }
  }
  if (getToken()) {
    console.log("有token,本地登录成功");
    if (store.getters.roles.length === 0) {
      // 判断当前用户是否已拉取完user_info信息
      store.dispatch('GetInfo').then(res => {
        // 拉取user_info
        const roles = res.roles
        store.dispatch('GenerateRoutes', {roles}).then(accessRoutes => {
          // 根据roles权限生成可访问的路由表
          router.addRoutes(accessRoutes) // 动态添加可访问路由表
          console.log("路由表添加成功");
          next({...to, replace: true}) // hack方法 确保addRoutes已完成
          NProgress.done()
        })
      })
        .catch(err => {
          store.dispatch('LogOut').then(() => {
            Message.error(err)
            console.log("退出登录");
            window.location.href = "http://serverIp:port/sso/oauth2/logout?post_logout_redirect_uri=http://localIp:port&response_type=code id_token&client_id=clientId" //外链地址
            next({path: '/'})
            // next()
          })
        })
    } else {
      next()
    }
  } else {
    console.log("无本地token,进入品高界面登录");
    // 带重定向地址跳转,登录成功后跳转回来,并在地址后附带用户登录参数
    window.location.href = "http://serverIp:port/sso/oauth2/authorize?redirect_uri=http://localIp:port/stage-api/api/v1/thirdParty/checkTokenThenLogin&response_type=code id_token&client_id=clientId";
    next(); // 放行
    // next(false);
    NProgress.done(); //关闭进度条
  }
后端解密token并本地登录

​ 后端接口checkTokenThenLogin,完成对返回token的解密获取用户信息,再检验本地是否有该用户,有则本地登录获取本地token。

    /**
     * 本地登录,检验id_token
     */
    @ApiOperation("校验id_token,完成本地登录")
    @GetMapping("/checkTokenThenLogin")
    public void checkTokenThenLogin(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 获取路径的参数部分code和id_token
        String queryString = request.getQueryString();
        if (queryString == null) {
            response.sendRedirect("http://44.85.17.9/login");
        }
        String[] queryParams = queryString.split("&");
        String token = "";
        for (String queryParam : queryParams) {
            if (queryParam.contains("id_token=")) {
                token = queryParam.replace("id_token=", "");
            }
        }
        System.out.println(token);
        // 校验id_token
        if (JwtUtil.verify(token)) {
            // 获取json类型的payload
            // 本地登录(品高能登录用户必然存在,若本地不存在账号,则重新获取个人用户信息并存入本地)
            String username = (String) JwtUtil.getVal(token, "username"); // 用户登录账号
            String name = (String) JwtUtil.getVal(token, "name");     // 用户名
            System.out.println(username + ":" + name);
            // 本地查询是否有该账号
            SysUser sysUser = userService.selectUserByUserName(username);
            if (sysUser == null) {
                SysUser user = SysUser.build(username, name);
                userService.insertUser(user);
            }
            String login = loginService.login(username, PASSWORD, null, null);
            System.out.println(login);
            response.sendRedirect("http://44.85.17.9/index?localToken=" + login);
        } else {
            response.sendRedirect("http://44.85.17.9/login");
        }
    }
前端获取到本地token,跳转到应用首页。

​ 在permission.js中,登录的路由守卫之前,先把token保存到本地的session里。

    // 跳转到index携参token
    if (to.path.indexOf('/index') !== -1) {
        console.log("token设置");
        if (to.query.localToken !== null && to.query.localToken !== undefined) {
            setToken(to.query.localToken)
        }
    }

​ 后端登录接口,解密第三方token获取用户信息,使用该信息进行本地登录,获取本地token。接口将本地token作为参数拼接在跳转的地址里,前端响应拦截器拦截到后,setToken方法将本地token存入session。路由守卫getToken方法拿到本地token后,拉取用户的基本信息和路由权限,加载完跳转到应用首页。

Web注销过程

​ 单点登录完成之后,当用户需要注销时,必须通过单点注销注销所有已经登录的应用系统。单点注销需要完成以下两步:

  • 注销所有已登陆的系统
  • 注销统一认证

​ 流程如下:

1635155298487

  1. 这里的注销地址在浏览器直接重定向即可,接口说明请看注销接口
  2. ua会在注销接口中直接返回一个注销页面,这个页面中包含了所有已登录的应用的注销地址。
  3. 注销所有已登录的应用
  4. 注销统一认证服务
  5. 重定向到最初传给注销接口的url。
注销接口
URLhttp://serverIp:port/serverPath/oauth2/logout
调用方式GET
参数post_logout_redirect_uri:可选,注销完成后跳转地址,没有传的话默认会跳转到统一认证根目录。

​ 注销不需要写后端接口,只需要重定向到统一认证注销地址,项目里需要写重定向的位置:

  • permission.js里,路由守卫拦截时,判断当前用户是否已拉取完user_info信息,如果有报错,则退出登录。
  • 页面相应拦截器,当检测到本地token过期时,跳出重新登录提示,确认后需要跳转到第三方登录界面。
  • 导航组件有退出登录的功能,需要匹配第三方注销跳转。
前端界面跳转

src\permission.js

// 路由守卫:主要用来通过跳转或取消的方式守卫路由
router.beforeEach((to, from, next) => {
  NProgress.start() // 开启进度条
  document.title = defSet.title
  // 跳转到index携参token
  if (to.path.indexOf('/index') !== -1) {
    console.log("token设置");
    if (to.query.localToken !== null && to.query.localToken !== undefined) {
      setToken(to.query.localToken)
    }
  }
  if (getToken()) {
    console.log("有token,本地登录成功");
    if (store.getters.roles.length === 0) {
      // 判断当前用户是否已拉取完user_info信息
      store.dispatch('GetInfo').then(res => {
        // 拉取user_info
        const roles = res.roles
        store.dispatch('GenerateRoutes', {roles}).then(accessRoutes => {
          // 根据roles权限生成可访问的路由表
          router.addRoutes(accessRoutes) // 动态添加可访问路由表
          console.log("路由表添加成功");
          next({...to, replace: true}) // hack方法 确保addRoutes已完成
          NProgress.done()
        })
      })
        .catch(err => {
          store.dispatch('LogOut').then(() => {
            Message.error(err)
            console.log("退出登录");
            window.location.href = "http://serverIp:port/sso/oauth2/logout?post_logout_redirect_uri=http://localIp:port&response_type=code id_token&client_id=clientId" //外链地址
            next({path: '/'})
            // next()
          })
        })
    } else {
      next()
    }
  } else {
    console.log("无本地token,进入品高界面登录");
    window.location.href = "http://serverIp:port/sso/oauth2/authorize?redirect_uri=http://localIp:port/stage-api/api/v1/thirdParty/checkTokenThenLogin&response_type=code id_token&client_id=clientId";
    next(); // 放行
    // next(false);
    NProgress.done(); //关闭进度条
  }

src\utils\request.js

// 响应拦截器
service.interceptors.response.use(res => {
    const code = res.data.code
    if (code === 401) {
      MessageBox.confirm(
        '登录状态已过期,您可以继续留在该页面,或者重新登录',
        '系统提示',
        {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }
      ).then(() => {
        store.dispatch('LogOut').then(() => {

          // location.href = '/index';
          // 重定位到外链地址
          location.reload() // 为了重新实例化vue-router对象 避免bug
          window.location.href = "http://serverIp:port/sso/oauth2/logout?post_logout_redirect_uri=http://localIp:port&response_type=code id_token&client_id=clientId" //外链地址
        })
      })
    } else if (code !== 200) {
      Notification.error({
        title: res.data.msg
      })
      return Promise.reject('error')
    } else {
      return res.data
    }
  },
  error => {
    console.log('err' + error)
    Message({
      message: error.message,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
)

src\layout\components\Navbar.vue

  methods: {
    toggleSideBar() {
      this.$store.dispatch('app/toggleSideBar')
    },
    async logout() {
      this.$confirm('确定注销并退出系统吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        this.$store.dispatch('LogOut').then(() => {
          location.reload()
          // location.href = '/index';
          window.location.href = "http://serverIp:port/sso/oauth2/logout?post_logout_redirect_uri=http://localIp:port&response_type=code id_token&client_id=clientId" //外链地址
        })
        })
      })
    }
  }
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐