The introduction

In the first place, why do not do proper work… Because our company on my front end, not obediently write page to write what SSO. I came up with the idea to write SSO SSO. First, I found that the login of the company is particularly messy. Each system is an independent login, and some businesses have some intersection. It happened that our backend brother was tackling a technical difficulty, so I started to write a single sign-on while waiting for the interface.

In terms of technology stack, NodeJS is used for back-end implementation, Express-session is used for local session maintenance, and Redis is used for session storage. Since the current projects are separated from the front and back ends, in order to better fit the current business logic, Changing the routine jump to passport authentication server login to the interface makes this SSO more suitable for use in SPA.

The following will elaborate on the implementation and summarize some points need to pay attention to, I hope my humble opinion can help you.

Realize the principle of

SSO stands for Single Sign On, which means that if you log in to one system in a multi-system application cluster, you can be authorized to log in to all other systems without having to log in again. SSO generally requires an independent authentication center (Passport), and the subsystem login must pass Passport. The subsystem itself will not participate in the login operation. When a system successfully logs in, Passport will issue a token to each subsystem, which can obtain their own protected resources by holding the token. To reduce frequent authentication, each subsystem, after being authorized by Passport, establishes a local session and does not need to initiate authentication to Passport again for a certain period of time.

One of the more common SSO implementations is shown in the figure taken from

The specific implementation

Prepare the environment

First, I need to do some preparatory work. In order to facilitate SSO testing, I need at least three domain names, here I directly simulate locally. If you have a server domain, you can skip this step.

Constructing a Local Domain Name (Mac)

1. Configure hosts file

// MacOS sudo vim /etc/hosts // Add the following lines: 127.0.0.1 testssoa.xxx.com 127.0.0.1 testssob.xxx.com 127.0.0.1 passport.xxx.comCopy the code

2. Add the nginx reverse proxy configuration

  1. Install nginx first
  2. Add the site configuration
vim /usr/local/etc/nginx/nginx.conf // Add the following three proxy servers {listen 1280; server_name passport.xxx.com; location / { 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_pass http://127.0.0.1:11000;
  }
}

server {
  listen 1280;
  server_name testssoa.xxx.com;

  location / {
    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_pass http://127.0.0.1:11001;
  }
}

server {
  listen 1280;
  server_name testssob.xxx.com;

  location / {
    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_pass http://127.0.0.1:11002; }}Copy the code
  1. Nginx -t checks whether the configuration is valid
  2. Nginx -s reload Restarts nginx

Prepare a simple login page

// package.json
"scripts": {
  "start": "babel-node passport.js"."starta": "cross-env NODE_ENV=ssoa babel-node index.js"."startb": "cross-env NODE_ENV=ssob babel-node index.js"
}

// index.js
import express from 'express' // import requires Babel support
const app = express()
const mapPort = {
  'ssoa': 11001.'ssob': 11002
}
const port = mapPort[process.env.NODE_ENV]
if (port) {
  console.log('listen port: ', port)
  app.listen(port)
}
Copy the code

Simple configuration so that two servers can be started directly by NPM run starta and NPM run startb

Specific steps

1. User login

All logins are initiated to Paspport, where JWT is used to maintain user logins (considering app). After successful logins, tokens are stored in Redis and written into domain xxx.com, the top-level domain name. In this way, tokens can be obtained by different subsystems. Setting httpOnly at the same time can prevent some XSS attacks.

app.post('/login'.async (req, res, next) => {
  // If the login succeeds, set the token for the cookie in the current domain
  const { username, password } = req.body

  // Select a user from the database by username and password
  try {
    const user = await authUser(username, password)
    const lastToken = user.token
    // The token is generated here. JWT is used here
    const newToken = jwt.sign(
      { username, id: user.id },
      tokenConfig.secret,
      { expiresIn: tokenConfig.expiresIn }
    )
    // Save the token to redis
    await storeToken(newToken)

    // The subsystem session needs to be cleared after the new token is generated
    if (lastToken) {
      await clearClientStore(lastToken)
      await deleteToken(lastToken)
    }

    res.setHeader(
      'Set-Cookie'.`token=${newToken}; domain=xxx.com; max-age=${tokenConfig.expiresIn}; httpOnly`)

    return res.json({
      code: 0.msg: 'success'})}catch (err) {
    next(new Error(err))
  }
})
Copy the code

2. User access to protected resources (authentication process)

After successful login, we can attempt to obtain protected resources. Passport sets a cookie for the domain name xxx.com, so both A.xxx.com and B.xxx.com can use the cookie to request resources from their respective servers. As mentioned earlier, authentication is required before requesting resources. After successful authentication, local sessions will be generated, and subsequent requests will not need authentication for a certain period of time.

// Initiate an authentication request
const authenticate = async (req) => {
  const cookies = splitCookies(req.headers.cookie)
  // Check whether there is a token. If there is no token, return the failed branch
  const token = cookies['token']
  if(! token) {throw new Error('token is required.')}const sid = cookies['sid']

  // If user is obtained, the user has logged in
  if (req.session.user) {
    return req.session.user
  }

  // Make an authentication request to the Passport server
  try {
    // sid should be the key in redis
    let response = await axiosInstance.post('/authenticate', {
      token,
      sid: defaultPrefix + req.sessionID,
      name: 'xxxx' // Can be used to distinguish specific subsystems
    })
    if(response.data.code ! = =0) {
      throw new Error(response.data.msg)
    }
    // After successful authentication, a local session is established and the user id is stored, for example, a UID or token
    req.session.user = response.data.data
    req.session.save()

    return response.data
  } catch (err) {
    throw err
  }
}
Copy the code

For subsystems that need access to SSO, the only thing that really needs to be done is to initiate authentication, so the cost of access is very low for the subsystem itself. It doesn’t matter if the subsystems in different languages implement it differently, the core thing here is to issue authentication to Passport, just pass in the parameters required for authentication as agreed, and passport takes care of the rest.

After successful authentication, the specific resources are obtained by each subsystem.

3. Passport

Authentication is mainly to check the validity of the token. First, it is to check whether the token exists in REDIS, second, it is to check whether the token is still valid and expired, and to parse out the user information. After the verification is successful, the subsystem needs to be registered (stored in REDIS, with the token as the key). Facilitate subsequent logout. There is also a small judgment added here, which is the judgment of X-real-IP, which can prevent a certain degree of forgery.

app.post('/authenticate'.async (req, res, next) => {
  const { token, sid, name } = req.body
  try {
    // Check whether the requested real IP address is an authorized system
    // Nginx will pass the real IP, forgery x-forward-for is invalid
    if(! checkSecurityIP(req.headers['x-real-ip']) {throw new Error('ip is invalid')}// Check whether the token still exists in redis and verify whether the token is valid, get the user name and user ID
    const tokenExists = await redisClient.existsAsync(token)
    if(! tokenExists) {throw new Error('token is invalid')}const { username, id } = await jwt.verify(token, tokenConfig.secret)
    // The registration subsystem was verified successfully
    register(token, sid, name)
    return res.json({
      code: 0.msg: 'success'.data: { username, id }
    })
  } catch (err) {
    // Clear should also be performed for token expiration
    next(new Error(err))
  }
})
Copy the code

4. Cancel the link

When a user logs out of a subsystem, it is necessary to log out of all subsystems in the domain. As session-related files have been stored in Redis before, all these sessions need to be cleared when logging out. Otherwise, the subsystem may still obtain resources within a certain period of time. Here I hand over the functions clearClientStore(Token) and deleteToken(token).

Problem thinking and summary

The whole process of SSO go down or are clear, but before do feel awkward quite difficult (or perhaps just hard for me this front-end), during this period also met a lot of strange questions, on the one hand they are for my own thinking often walk slanting problem, on the other hand is not enough skilled, touch stone across the river. I ran into problems during this period, but I also looked at some source code implementations such as Express-session and connect-Redis before I could understand them.

Problems encountered and solutions

  1. I keep regenerate sessions with the Express-session and wonder what my sessions are not regenerate. Then I read the source code under the guidance of some big guy and I find that the middleware already does something for me. For session operations I only need to do the simplest set and GET.
  2. Redis has been unable to read the session key. This problem is found in the source code of connect-Redis. It will add a prefix to sid by default'sess:', so you must get sid from redisget prefix + sid

Deeply realize that sometimes struggling to solve a problem, it must be before the idea of a problem, at this time must calm down to find the root of the problem, for programmers to find the root of the problem is the most effective way to read the source code.

In the design process, we also consider how to reduce the access cost of subsystems (only one step of authentication is required), security considerations (httpOnly, RealIP filtering, session validity, etc.) and performance considerations (local sessions and REDis).

Finally, the complete sample code is attached, begging you to give a Star, little brother in this grateful, code config folder ignore, there is only a database configuration items and salt parameters. Passport should be ready to use with a few tweaks.

Still have a lot of inconsiderate place, hope each big guy can give trifles give directions.