前端实现keycloak + nodejs实现单点登录(SSO)

929 阅读7分钟

前言

  1. 最近项目需要迁入SSO登录认证功能
  2. 于是需要搞懂一件事情就是什么是单点登录
  3. 最好本地实现一个比较规范的单点登录事情
  4. 你将可以更加深入的了解什么是单点登录等

单点登录(Single Sign-On,SSO)是一种认证过程,允许用户使用一个集中的身份验证服务登录多个相关但独立的软件系统。在一个系统中成功登录后,用户能够自动访问其他子系统,而无需重新登录。实现子系统A登录后B系统自动可以访问,通常需要以下几个步骤:

  1. 选择SSO协议:常见的SSO协议有OAuth 2.0、OpenID Connect和SAML(Security Assertion Markup Language)。根据具体需求选择合适的协议。
  2. 配置身份提供者(Identity Provider,IdP) :IdP是负责用户身份验证的中心服务。常见的IdP有Keycloak、Auth0、Okta等。IdP将管理用户的认证和授权。
  3. 配置服务提供者(Service Provider,SP) :SP是需要使用SSO的各个子系统,如子系统A和子系统B。SP与IdP进行交互,以验证用户身份。
  4. 共享认证凭证:当用户在子系统A中成功登录后,IdP会生成一个认证凭证(如JWT token或SAML assertion),并通过安全的方式传递给子系统A。子系统A可以将该凭证保存在用户的会话或cookie中。
  5. 传递凭证:子系统B在用户访问时,会检查用户的会话或cookie中是否存在有效的认证凭证。如果存在,子系统B将凭证发送给IdP进行验证。如果验证通过,用户将被认为是已登录状态。
  6. 跨域问题:如果子系统A和子系统B在不同的域名下,需要解决跨域问题。可以使用浏览器的跨域cookie机制(如SameSite=None; Secure),或者通过反向代理等方式解决。

工作原理

  1. 用户认证:用户通过SSO系统的登录页面输入凭证(如用户名和密码)。
  2. 凭证验证:SSO系统验证用户的凭证。如果验证成功,生成一个认证令牌(Token)。
  3. 令牌传递:用户访问其他应用时,SSO系统将认证令牌传递给这些应用。
  4. 验证令牌:每个应用接收到令牌后,向SSO系统验证令牌的有效性。如果令牌有效,允许用户访问该应用。

优势

  1. 简化用户体验:用户只需一次登录,便可访问多个系统,避免频繁输入用户名和密码。
  2. 提高安全性:集中管理用户认证,便于统一控制和监控,减少了弱密码和重复密码的风险。
  3. 减少管理成本:减少了密码重置和管理的次数,提高了IT管理效率。
  4. 统一身份管理:便于对用户的访问权限进行集中管理和审计。

本次我们模拟**OpenID Connect**标准搭建一套sso认证

  • keycloak

  • 我们采用docker部署搭建本地keycloak环境

// 拉取镜像
docker pull quay.io/keycloak/keycloak:latest


// 运行
docker run -d \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  -p 8080:8080 \
  --name keycloak \
  quay.io/keycloak/keycloak:latest \
  start-dev

这里:

-   `-e KEYCLOAK_ADMIN=admin``-e KEYCLOAK_ADMIN_PASSWORD=admin` 设置了初始的管理员用户名和密码。
-   `-p 8080:8080` 将容器的 8080 端口映射到主机的 8080 端口。
-   `--name keycloak` 为容器指定了一个名字。
-   `start-dev` 是 Keycloak 的开发模式启动命令。


或者 本地创建docker-compose.yml 文件


version: '3'

services:
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin
    ports:
      - "8080:8080"
    command:
      - start-dev


docker-compose up -d启动也可以

上面我们是基于开发环境搭建的keycloak环境

生产环境需要构建数据库等配置,并允许https和证书等自行查阅

image.png

nginx反向代理

server {
    listen 80;
    server_name your_keycloak_hostname;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

步骤 1: 登录到 Keycloak 管理控制台

  1. 打开浏览器,访问你的 Keycloak 管理控制台。例如,如果你在本地运行 Keycloak Docker 容器,访问 http://localhost:8080
  2. 使用你在启动 Keycloak 容器时设置的管理员用户名和密码登录(例如,用户名为 admin,密码为 admin)。 访问 Keycloak: 在浏览器中打开 http://localhost:8080,你将看到 Keycloak 的登录界面。使用你在上一步中设置的管理员用户名和密码进行登录

image.png

步骤 2: 创建一个 Realm

  1. 登录后,点击左上角的 Master 下拉菜单,选择 Add realm
  2. Add Realm 页面中,填写 Realm Name,例如 myrealm
  3. 点击 Create

image.png

步骤 3: 创建一个客户端

  1. 进入 myrealm 后,在左侧菜单中选择 Clients
  2. 点击右上角的 Create 按钮。
  3. 填写客户端 ID,例如 myclient,然后点击 Save

image.png

步骤 4: 配置客户端

  1. 在客户端配置页面,选择 Settings 选项卡。

  2. 找到 Access Type 选项,设置为 confidential。该选项可能在页面中间部分。

  3. 配置其他必要的选项:

    • Valid Redirect URIs:填写你的应用程序的回调 URL,例如 http://localhost:3000/auth/callback
    • Web Origins:可以填写 * 或者你的应用程序的根 URL,例如 http://localhost:3000
  4. 点击 Save

image.png

步骤 5: 获取客户端密钥

  1. 在客户端配置页面,选择 Credentials 选项卡。
  2. Client Authenticator 下拉菜单中选择 Client Id and Secret
  3. 你会看到一个 Secret 字段,复制这个值,这就是你的客户端密钥。

image.png

实现方案

项目地址

前端搭建两套系统 采用vite + react
后端 模拟采用node + express等

代码分析

假设A系统前端页面

// clientA/src/App.js
import React, { useEffect, useState } from "react";
import axios from "axios";

function App() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    axios.get("/api/user", { withCredentials: true }).then((response) => {
      setUser(response.data.user);
    });
  }, []);

  return (
    <div>
      {user ? (
        <div>
          <h1>Welcome, {user.displayName}</h1>
          <a href="http://localhost:8011">Go to Subsystem B</a>
        </div>
      ) : (
        <a href="/api/login">Login</a>
      )}
    </div>
  );
}

export default App;

vite代理允许跨域访问

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 8010,
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

系统A后端代码

passport-openidconnect

// serverA.js

import express from "express";
import session from "express-session";
import passport from "passport";
import OpenIDConnectStrategy from "passport-openidconnect";
import crypto from "crypto";

const secret = crypto.randomBytes(64).toString("hex");

const app = express();

app.use(session({ secret, resave: false, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());

passport.use(
  new OpenIDConnectStrategy(
    {
      issuer: "http://localhost:8080/realms/myrealm",
      authorizationURL:
        "http://localhost:8080/realms/myrealm/protocol/openid-connect/auth",
      tokenURL:
        "http://localhost:8080/realms/myrealm/protocol/openid-connect/token",
      userInfoURL:
        "http://localhost:8080/realms/myrealm/protocol/openid-connect/userinfo",
      clientID: "myclient",
      clientSecret: "dSC9rxQOhh8MYgzOnJ45DUMaoIOaq4pX",
      callbackURL: "http://localhost:3000/auth/callback",
    },
    (issuer, profile, cb) => {
      try {
        // Here you can handle the profile data and access tokens as needed
        return cb(null, profile); // Call done with `null` for error and `user` object
      } catch (error) {
        return cb(error); // Call done with error if something goes wrong
      }
    }
  )
);

passport.serializeUser((user, done) => {
  console.log("---serializeUser------");
  done(null, user);
});

passport.deserializeUser((obj, done) => {
  console.log("-----deserializeUser------");
  done(null, obj);
});

const isAuthenticated = (req, res, next) => {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect("/login");
};

app.get("/login", passport.authenticate("openidconnect"));

app.get(
  "/auth/callback",
  passport.authenticate("openidconnect", {
    failureRedirect: "/login",
    failureMessage: true,
  }),
  (req, res) => {
    res.redirect("/");
  }
);

app.get("/", isAuthenticated, (req, res) => {
  res.send(`Hello, ${req.user.displayName}`);
});

app.get("/user", (req, res) => {
  if (req.isAuthenticated()) {
    res.json({ user: req.user });
  } else {
    res.json({ user: null });
  }
});

app.listen(3000, () => {
  console.log("Server A started on http://localhost:3000");
});

参数解释

  • issuer:认证服务器的标识符,在这里是 Keycloak 的地址以及具体的 Realm。这告诉 Passport.js 要使用哪个认证服务器。

    issuer: 'https://your-keycloak-domain/auth/realms/myrealm'
    
  • authorizationURL:用户认证的 URL,用户会被重定向到这个 URL 进行登录。

    authorizationURL: 'https://your-keycloak-domain/auth/realms/myrealm/protocol/openid-connect/auth'
    
  • tokenURL:用于交换认证代码以获取访问令牌的 URL。

    tokenURL: 'https://your-keycloak-domain/auth/realms/myrealm/protocol/openid-connect/token'
    
  • userInfoURL:用于获取用户信息的 URL。

    userInfoURL: 'https://your-keycloak-domain/auth/realms/myrealm/protocol/openid-connect/userinfo'
    
  • clientID:在 Keycloak 上注册的客户端 ID,这个 ID 标识了哪个客户端(子系统B)在请求认证。

    clientID: 'clientB'
    
  • clientSecret:客户端的密钥,用于身份验证,确保请求来自合法的客户端。

    clientSecret: 'your-client-secret'
    
  • callbackURL:认证成功后,Keycloak 会重定向用户到这个 URL。这个 URL 通常是客户端应用程序的一个端点,用于处理认证响应。

    callbackURL: 'http://localhost:3001/auth/callback'
    

    启动命令测试

    后台
    node server_a.js # 启动a服务
    node server_b.js # 启动b服务
    
    前台
    a系统前台服务
    cd react-app-one
    pnpm dev
    
    b系统前台服务
    cd react-app-two
    pnpm dev
    
  1. 访问localhost:8010

image.png

  1. 点击登录去认证

image.png 3.登录我们创建好的授权用户

- username: abc
- password:123456

这在我们的管理系统可以查阅 image.png

image.png

登录成功

image.png

image.png

此时我们直接访问localhost:8011已经可以自动认证登录成功

image.png