登陸系統實現

對於前端來講,登陸就是把用戶信息提交上去,後續就不用前端去擔憂了。可是作過一個登錄sdk的項目,發現這裏邊的邏輯不是那麼簡單。下面是我對登錄的一些理解分享給你們前端

session & JWT

http協議是無狀態的,它不能以狀態來區分和管理請求和響應。也就是說,若是用戶經過帳號和密碼來進行用戶認證後,在下次請求時,用戶還須要在再次進行用戶認證。由於根據http協議,服務端並不知道是哪一個用戶發起的請求。爲了識別當前的用戶,服務端與客戶端須要約定某個標識表示當前的用戶node

session

爲了識別是哪一個用戶發出的請求,須要在服務端存儲一份用戶登陸的信息,這份登陸信息會在響應傳遞給客戶端進行存儲,當下次請求的時候客戶端會攜帶登陸信息請求服務端,服務端就可以區分請求是哪一個用戶發起的 下面是示意圖: session方案中,請求服務端時會攜帶session_id,服務端會經過當前的session_id,去查詢數據庫當前session是否有效,若是有效後續請求就可以標識當前用戶。ios

若是當前的session是無效的或者是不存在的,客戶端須要重定向到登陸頁面,或者提示沒有登陸 下面是對應的代碼:git

const express = require('express');
const session = require('express-session')
const redis = require('redis')
const connect = require('connect-redis')
const bodyParser = require('body-parser')

const app = express();
app.use(bodyParser.json());

app.use(bodyParser.urlencoded({ extended: true }))

const RedisStore = connect(session);

const client = redis.createClient({
  host: '127.0.0.1',
  port: 6397
})

app.use(session({
  store: new RedisStore({
    client,
  }),
  secret: 'sec_id',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,
    httpOnly: true,
    maxAge: 1000 * 60 * 10
  }
}))


app.get('/', (req, res) => {
  sec = req.session;
  if (sec.user) {
    res.json({
      user: sec.user
    })
  } else {
    res.redirect('/login')
  }
})


app.post('/login', (req, res) => {
  const {pwd, name } = req.body;
  // 這裏爲了簡便,就寫簡單點
  if (pwd === name) {
    req.session.user = req.body.name;
    res.json({
      message: 'success'
    })
  }
})
複製代碼

當請求/接口的時候,會判斷當前session是否存在。若是存在,就返回對應的信息;若是不存在,則會重定向到/login頁面。這個頁面登陸成功之後,就會設置sessiongithub

上面代碼中只考慮了單個服務的場景,可是業務中每每是多個服務,服務域名不同,因爲cookie不能跨域,因此session的共享會存在必定問題 例若有上面場景中,用戶首先請求服務Auth Server,而後生成session。當用戶再次請求服務feedback Server時,因爲session不共享,就致使服務B拿不到登錄態,就須要從新登陸。web

session的缺點

session用於解決鑑權,存在一些缺點:redis

  1. 多集羣支持: 當網站採用集羣部署的時候,會遇到多臺web服務器之間如何作session共享的問題。由於session是由單個服務建立,處理請求的服務器可能不是建立session的服務器,那麼該服務器就沒法拿到以前放入到session中的登陸憑證之類的信息
  2. 性能差: 當流量高峯期時,因爲每一個請求的用戶信息都須要存儲在數據庫中,對資源會是一種負擔
  3. 低擴展性:當擴容服務端的時候,session store也須要擴容。這會佔用額外的資源和增長複雜性

JWT

session服務中,服務器須要維護用戶的session對象,要麼前置一個服務,要麼每一個服務都從存儲層中獲取session信息,請求量大的時候IO壓力大。算法

相比於session服務,把用戶信息存放在客戶端,每次請求的時候隨cookiehttp頭部渠道發送到服務器上,就可讓服務器變成無狀態的存在,從而減輕服務器的壓力。
相比於瀏覽器,Native App設置cookie沒有那麼容易,因此服務端須要採用另一種認證方式。在登陸後,服務端會根據登陸信息生成一個token值,後續的請求客戶端請求會攜帶token值進行登陸校驗。數據庫

jwt主要由三部分構成: 頭部信息(header)、消息體(payload)和簽名(signature) 頭信息指定了JWT的簽名算法express

header = {
  alg: "HS256",
  type: "JWT"
}
複製代碼

HS256表示使用了 HMAC-SHA256 來生成簽名 消息體包含了JWT的意圖:

payload = {
  "loggedInAs": "admin",
  "iat": 1422779638
}
複製代碼

未簽名的令牌由base64url編碼的頭信息和消息體拼接而成,簽名則經過私有的key計算而成:

key = 'your_key'
unsignedToken = encodeBase64(header) + "." + encodeBase64(payload)
signature = HAMC-SHA256(key, unsignedToken)
複製代碼

最後在未簽名的令牌尾部拼接上base64url編碼的簽名就是JWT了:

token = encodeBase64(header) + '.' + encodeBase64(payload) + '.' + encodeBase64(signature)
複製代碼

具體實現

首先建立app.js,用於獲取請求參數,還有監聽端口等等

// app.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
const router = require('./router');
const app = express();

app.use(bodyParser.json())
app.use(cookieParser);

app.use(bodyParser.urlencoded({ extended: true }))

router(app);


app.listen(3001, () => {
  console.log('server start')
})
複製代碼

dotenv主要用於配置環境變量,建立.env文件,下面是本示例的配置:

ACCESS_TOKEN_SECRET=swsh23hjddnns
ACCESS_TOKEN_LIFE=1200000
複製代碼

而後註冊login接口,這個接口提交用戶信息到server,後端會用這些信息生成對應的token,能夠直接返回給客戶端或者設置cookie

// user.js
const jwt = require('jsonwebtoken')

function login(req, res) {
  const username = req.body.username;

  const payload = {
    username,
  }
  
  const accessToken = jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, {
    algorithm: "HS256",
    expiresIn: process.env.ACCESS_TOKEN_LIFE
  })

  res.cookie('jwt', accessToken, {
    secure: true,
    httpOnly: true,
  })
  res.send();
}
複製代碼

當登陸成功之後直接設置客戶端的cookie

下次請求的時候,服務端直接獲取用戶的jwt cookie,判斷當前token是不是有效的:

//middleware.js
const jwt = require('jsonwebtoken');

exports.verify = function(req, res, next) {
  const accessToken = req.cookies.jwt;

  try {
    jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET);
    next();
  } catch (error) {
    console.log(error);
    return res.status(401).send();
  }
}
複製代碼

相對於session的方式,jwt具備如下優點:

  1. 擴展性好:在分佈式部署場景下,session須要數據共享,而jwt不須要
  2. 無狀態: 不須要在服務端存儲任何狀態

jwt也存在一些缺點:

  1. 沒法廢棄: 在簽發後,在到期以前會始終有效,沒法中途廢棄。
  2. 性能差: session方案中,cookie須要攜帶的sessionId是一個很短的字符串。可是因爲jwt是無狀態的,須要攜帶一些必要的信息,體積會比較大。
  3. 安全性:jwt中的payload是base64編碼的,沒有加密,所以不能存儲敏感數據
  4. 續簽: 傳統的cookie續簽方案都是框架自帶的,session有效期30分鐘,30分鐘內若是有訪問,有效期被刷新至30分鐘。若是要改變jwt的有效時間,就須要簽發新的jwt。一種方案是每次請求都更新jwt,這樣性能太差了;第二種方案爲每一個jwt設置過時時間,每次訪問刷新jwt的過時時間,就失去了jwt無狀態的優點了。

session和jwt的適用場景

適合適用jwt的場景:

  • 有效期短
  • 只但願被使用一次

例如在請求服務A的時候,服務A會頒發一個很短過時時間的JWT給瀏覽器,瀏覽器能夠當前的jwt去請求服務B,服務B則能夠經過校驗JWT來判斷當前用戶是否有權操做。 因爲jwt具備沒法廢棄的特性,單點登陸和會話管理很是不適合用jwt。

單點登陸(SSO)

sso一般處理的是一個公司的不一樣應用間的訪問登陸問題。如企業應用有不少業務子系統,只須要登陸一個系統,就能夠實現不一樣子系統間的跳轉,而避免了登陸操做。 這裏舉個例子進行說明: 子系統A統一到passport域名登陸,而且在passport域名下種上cookie,而後把token加入到url中,重定向到子系統A 回到子系統A後,使用token再次去passport驗證,若是驗證經過返回必要的信息生成系統A的session 當系統A下次請求的時候會當前服務已有session,不會再去passport去權限校驗 當訪問系統B的時候,因爲系統B不存在session,因此會重定向到passport域名,passport域名下面已經有cookie了,因此不須要登陸,直接把token加入到url中,重定向到子系統B,後續流程和A同樣

實現原理

以騰訊爲例,騰訊旗下有多個域名,例如: cd.qq.com、tencent.com、jd.cm、music.qq.com 在cd.qq.commusic.qq.com,咱們能夠設置cookiedomianqq.com實現cookie的共享。 可是如cd.qq.comtencent.com二級域名不一致,讓全部的域名都能共享一個cookie。因此但願有一個通用的服務去承載這個登陸服務。例如在騰訊有這樣一個域名: passport.tencent.com用於專門登陸服務的承載。這個時候cd.qq.comtencent.com的登陸登出都由sso(passport.baidu.com)來實現

具體實現

成功登陸SSO會生成token跳轉到源頁面,此時SSO已經有登陸狀態,可是子系統仍然沒有登陸態。子系統須要經過token設置當前子系統的登陸態,並經過當前的token請求passport服務獲取用戶的基本信息。 下面主要講三個部分 passport: 登陸服務,域名爲passport.com system: 子系統,監聽端口3001爲系統A,監聽端口3002爲系統B,域名分別爲a.comb.com

passport服務

passport主要有如下幾個功能:

  1. 統一登陸服務
  2. 獲取用戶信息
  3. 校驗當前的token是不是有效的

首先實現登陸頁面的一些邏輯:

// passport.js
import express from 'express';
import session from 'express-session';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import connect from 'connect-redis';
import redis from '../redis';

const app = express();
app.use(bodyParser.json());

app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser());

app.set('view engine', 'ejs');
app.set('views', `${__dirname}/views`);

const RedisStore = connect(session);


app.use(
  session({
    store: new RedisStore({
      client: redis,
    }),
    secret: 'token',
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: true,
      httpOnly: true,
      maxAge: 1000 * 60 * 10,
    },
  })
);

app.get('/', (req, res) => {
  const { token } = req.cookies;
  if (token) {
    const { from } = req.query;
    const has_access = await redis.get(token);
    if (has_access && from) {
      return res.redirect(`https://${from}?token=${token}`);
    }
    // 若是不存在便引導至登陸頁從新登陸
    return res.render('index', {
      query: req.query,
    });
  }
  return res.render('index', {
    query: req.query,
  });
})
app.port('/login', (req, res) => {
  const { name, pwd, from } = req.body;

  if (name === pwd) {
    const token = `${new Date().getTime()}_${ name}`;
    redis.set(token, name);
    res.cookie('token', token);
    if (from) {
      return res.redirect(`https://${from}?token=${token}`);
    }
  } else {
    console.log('登陸失敗');
  }
})
複製代碼

/接口首先判斷passport是否已經有登陸成功的token,若是存在就在去存儲中查找當前token是不是有效的。若是有效而且參數中攜帶from參數,那麼就跳轉到原頁面而且把生成的token值帶回到原頁面。

下面是passport頁面的樣式: 登陸接口須要作的就是登陸成功後設置passport域名的token,而後重定向到以前的頁面

子系統實現

import express from 'express';
import axios from 'axios';
import session from 'express-session';
import bodyParser from 'body-parser';
import connect from 'connect-redis';
import cookieParser from 'cookie-parser';
import redisClient from "../redis";
import { argv } from 'yargs';
const app = express();

const RedisStore = connect(session);
app.use(bodyParser.json());

app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser('system'));

app.use(session({
  store: new RedisStore({
    client: redisClient,
  }),
  secret: 'system',
  resave: false,
  name: 'system_id',
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    maxAge: 1000 * 60 * 10
  }
}))


app.get('/', async (req, res) => {
  const { token } = req.query;
  const { host } = req.headers;
  // 若是本站已經存在憑證,便不須要去passport鑑權
  if (req.session.user) {
    return res.send('user success')
  }

  // 若是沒有本站信息,有沒有token,便去passport登陸鑑權
  if (!token) {
    return res.redirect(`http://passport.com?from=${host}`)
  }

  const {data} = await axios.post('http://127.0.0.1:3000/check',{
    token,
  })
  // 驗證成功
  if (data?.code === 0) {
    const user = data?.user;
    req.session.user = user;
  } else {
    // 驗證失敗
    return res.redirect(`http://passport.com?from=${host}`)
  }
  return res.send('page has token')
})
app.listen(argv.port, () => {
  console.log(argv.port);
})
複製代碼

首先判斷當前子系統是否已經登陸了,若是當前系統session已經存在,就返回user success。若是沒有登陸而且url上攜帶token參數,就須要跳轉到passport.com登陸。

若是token存在,而且當前子系統沒有登陸,就須要使用當前頁面的token去請求passport服務,判斷這個token是否有效的,若是有效就返回相應的信息,而且設置session

這裏系統A和系統B只是監聽的接口不一樣,因此在啓動參數中添加變量獲取啓動端口

passport鑑權服務

app.get('/check', (req, res) => {
  const { token } = req.query;
  if (!token) {
    return res.json({
      code: 1
    })
  }
  const user = await redis.getAsync(token);
  if (user) {
    return res.json({
      code: 0,
      user,
    })
  } else {
    return res.redirect('passport.com')
  }
})
複製代碼

check接口就是判斷請求服務的token是不是有效的,若是有效就返回對應的用戶信息,若是無效就重定向到passport.com從新登陸

OAuth

OAuth協議被普遍應用於第三方受權登陸中,藉助第三方登陸可讓用戶規避再次登陸的問題。

github受權爲例,講解OAuth的受權過程:

  1. 訪問服務A,服務A沒有登陸,能夠經過github第三方登陸
  2. 點擊github,跳轉到認證服務器。而後詢問是否受權
  3. 受權完成後,會重定向到服務A的一個路徑,而且攜帶參數code
  4. 服務A經過code去請求github,獲取到token
  5. 經過token值,再去請求github資源服務器獲取到你想要的的數據

首先去github-auth申請一個auth應用,例如如下:

執行後會獲得對應的client_idclient_secret。下面是具體的受權代碼(啓動服務就不寫,大同小異):

import { AuthorizationCode } from 'simple-oauth2';
const config = {
  client: {
    id: 'client_id',
    secret: 'client_secret'
  },
  auth: {
    tokenHost: 'https://github.com',
    tokenPath: '/login/oauth/access_token',
    authorizePath: '/login/oauth/authorize'
  }
}

const client = new AuthorizationCode(config);
const authorizationUri = client.authorizeURL({
  redirect_uri: 'http://localhost:3000/callback',
  scope: 'notifications',
  state: '3(#0/!~'
});

app.set('view engine', 'ejs');
app.set('views', `${__dirname}/views`);

app.get('/auth', (_, res) => {
  res.redirect(authorizationUri)
})
複製代碼

上面使用了simple-oauth2用於oauth2的講解,當訪問localhost:3000/auth的時候,服務會自動跳轉到github的認證地址下面是具體的地址

https://github.com/login/oauth/authorize?response_type=code&client_id=86f4138f17d0c3033ca4&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=notifications&state=3(%230%2F!~
複製代碼

當點擊受權後會重定向到localhost:3000/callback,而且url上攜帶參數code。下面是服務端的處理函數

async function getUserInfo(token) {
  const res = await axios({
    method: 'GET',
    url: 'https://api.github.com/user',
    headers: {
      Authorization: `token ${token}`
    }
  })
  return res.data;
}

app.get('/callback', async (req, res) => {
  const { code } = req.query;
  console.log(code);
  // 獲取token

  const options = {
    code,
  }

  try {
    const access = await client.getToken(options);
    const resp = await getUserInfo(access.token.access_token);
    return res.status(200).json({
      token: access.token,
      user: resp,
    });
  } catch (error) {
    
  }
})
複製代碼

根據url上參數code獲取到token,而後根據這個token去請求github api服務,獲取到用戶信息,一般網站會根據當前獲取到的用戶信息完成註冊、加session等一系列操做。上面代碼中,把用戶請求數據簡單返回給返回給前端,下面是最後返回給前端的數據格式: 最後就實現了第三方的登陸受權

參考文檔

medium.com/@siddhartha…
livecodestream.dev/post/a-prac…
medium.com/myplanet-mu…

相關文章
相關標籤/搜索