jwt與session的登入鑑權

利維亞的傑洛特發表於2018-09-16

image

前言

本文是我對自己學習的一個總結,我只是一名菜鳥,如果您有更好的方案或者意見請務必指正出來。我的?:1025873823

session鑑權

什麼是session?

session是一種伺服器機制,是儲存在伺服器上的資訊。儲存方式多種多樣,可以是伺服器的記憶體中,或者是mongo資料庫,redis記憶體資料庫中。而session是基於cookie實現的(伺服器會生成sessionID)通過set-cookie的方式寫入到客戶端的cookie中。每一次的請求都會攜帶伺服器寫入的sessionID傳送給服務端,通過解析sessionID與伺服器端儲存的session,來判斷使用者是否登入。

鑑權步驟如下:

  1. 客戶端發起登入請求,伺服器端建立session,並通過set-cookie將生成的sessionID寫入的客戶端的cookie中。
  2. 在發起其他需要許可權的介面的時候,客戶端的請求體的Header部分會攜帶sessionID傳送給服務端。然後根據這個sessionId去找伺服器端儲存的該客戶端的session,然後判斷該請求是否合法。

session鑑權的示例

基於Passport和Express的實現,Passport的詳細文件請參考,我這篇文章只是使用的介紹,更詳細的方法是閱讀文件。

跨域的解決

由於是前端分離的專案,前端的靜態資源服務和後端的介面可能不在同一個域名下,這就導致了伺服器無法在瀏覽器上寫入cookie。需要通過設定CORS解決。前後端都需要額外的設定,程式碼如下


// 基於axios程式碼設定如下, withCredentials: true是否允許跨域修改cookie

const Axios = axios.create({
  baseURL: 'http://127.0.0.1:3000/',
  timeout: 1000,
  withCredentials: true,
  responseType: "json",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded;charset=utf-8"
  }
})
複製程式碼
// 使用CORS模組,並配置允許跨域請求
app.use(cors({
  origin: 'http://127.0.0.1:8080',
  credentials: true,
  methods: ['PUT', 'POST', 'GET', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Content-Length', 'Authorization', 'Accept', 'X-Requested-With']
}))
複製程式碼

⚠️:這裡有一個坑,origin不能設定為萬用字元*,stackoverflow上的解答

Passport local本地驗證

環境配置
const session = require('express-session')
const MongoStore = require('connect-mongo')(session)
const bodyParser = require('body-parser')
const passport = require('passport')

app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
// 這裡我將session儲存到mongo中,更好的做法是儲存到redis中
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: sessionConfig.secret,
  store: new MongoStore({
    mongooseConnection: connection
  }),
  cookie: {
    maxAge: 60 * 1000 * 30
  }
}))
app.use(passport.initialize())
app.use(passport.session())
複製程式碼
配置策略

local驗證預設使用密碼和使用者名稱驗證,首先需要對local策略做出配置。以下是官方示例給出的程式碼,我直接copy過來使用。程式碼非常簡單。User是Mongoose的Model(需要自己建立),通過findOne方法查詢使用者名稱對應的使用者,並對查詢的結果作出判斷,並通過呼叫passport的done方法作出驗證回撥。由於User密碼不是明文儲存的,通過了bcrypt模組進行了加密。所以需要通過bcrypt.compare方法進行密碼校驗操作。

done方法是由passport提供的,用於回撥操作的方法。對於不同的結果執行不同的回撥操作。

const passport = require('passport')
const LocalStrategy = require('passport-local').Strategy
const User = require('../models/user')
const bcrypt = require('../util/bcrypt')

passport.use(new LocalStrategy((username, password, done) => {
  User.findOne({ username }, (err, user) => {
    if (err) return done(err)
    if (!user) {
      return done(null, false, { message: '使用者名稱不存在' })
    }
    if (!bcrypt.compare(password, user.password)) {
      return done(null, false, { message: '使用者名稱或密碼錯誤' })
    }
    return done(null, user)
  })
}))
複製程式碼
session序列化與反序列化

serializeUser序列化,將使用者資訊儲存到session中,這段資訊即是sessionID,同時會將sessionID儲存到客戶端的cookie中的過程。

deserializeUser反序列化,引數是使用者提交的sessionID,如果存在則從資料庫中查詢user並儲存與req.user中。


passport.serializeUser((user, done) => {
  done(null, user.id)
})

passport.deserializeUser((id, done) => {
  User.findById(id, (err, user) => {
    done(null, user)
  })
})
複製程式碼

??對於這段程式碼具體實現的細節,我一開始也明白。有一天我在stackoverflow上找到解答Understanding passport serialize deserialize

Q: serializeUser 做了什麼?

A: 通過done將user儲存到了session中, 並將sessionID寫入到客戶端的cookie上, 將使用者資訊附加到請求物件req.session.passport.user上。

Q:deserializeUser 做了什麼?

A:deserializeUser的第一個引數就是你儲存的sessionID,通過Model的findById方法查詢資料庫,並將使用者資訊附加到請求物件req.user上


passport.serializeUser(function(user, done) {
    done(null, user.id);
                 |
});              | 
                 |
                 |____________________> saved to session req.session.passport.user = {id:'..'}
                                   |
                                  \|/           
passport.deserializeUser(function(id, done) {
                   ________________|
                   |
                  \|/ 
    User.findById(id, function(err, user) {
        done(err, user);
                   |______________>user object attaches to the request as req.user

});
複製程式碼
logIn, logOut, isAuthenticated

passport為request物件擴充套件的方法

  • logIn(), 使用者登陸操作,即初始化session
  • logOut(), 使用者登出操作,刪除使用者的session資訊
  • isAuthenticated(), 用來判斷使用者是否登陸
介面示例

// 使用者登入
router.get('/login', (req, res, next) => {
  // 登入認證,使用local策略
  passport.authenticate('local', (err, user, info) => {
    if (err) return next(err)
    if (!user) return res.status(400).json({
      message: info.message
    })
    // 初始化session資訊
    req.logIn(user, (err) => {
      if (err) return next(err)
      res.status(200).json({ code: 200, message: '登陸成功' })
    })
  })(req, res, next)
})

// 使用者登出
router.get('/logout', isAuthenticated, (req, res) => {
  // 刪除mongo中的session資訊
  req.logout()
  res.status(200).json({ code: 200, message: '登出成功' })
})

// 使用者詳情(需要許可權的介面)
// isAuthenticated是通過passport提供的isAuthenticated()封裝的簡單中介軟體
// 新增isAuthenticated介面則是需要登陸許可權的介面
router.get('/details', isAuthenticated, (req, res) => {
  const { _id } = req.user
  UserService.getUserDetail(_id).then(data => {
    res.status(200).json({ code: 200, message: 'success', data })
  }).catch(err => {
    res.status(400).json({ message: '使用者資訊不存在' })
  })
})
複製程式碼
module.exports = function isAuthenticated (req, res, next) {
  if (req.isAuthenticated()) return next()
  res.status(403).json({ message: '沒有許可權' })
}
複製程式碼

jwt鑑權

什麼是jwt?

Json web token (JWT), 是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準((RFC 7519).該token被設計為緊湊且安全的,特別適用於分散式站點的單點登入(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證,也可被加密。

jwt鑑權的流程

鑑權的流程:

  1. 瀏覽器發起登入請求
  2. 請求通過後,伺服器會向瀏覽器返回token
  3. 瀏覽器接收到token後需要講token儲存到本地(比如localStorage)
  4. 瀏覽器在下一次請求的時候會攜帶token資訊
  5. 伺服器收到請求,去驗證token驗證成功後會返回資訊

乍一看,token是類似sessionID的存在。其實token和sessionID還是有一定的不同的。sessionID是基於cookie實現的,而token不需要基於cookie。這就導致了sessionID只能用在瀏覽器上,對於原生的應用無法實現。原生的應用是不具備cookie的特性的。另外sessionID可以實現服務端登出會話,而token不能(當然你可以把使用者登陸的token存入到redis中,但是不推薦token入庫)

jwt鑑權的示例

示例程式碼我是基本照抄這一篇教程,英文好的同學推薦閱讀原版Authenticate a Node.js API with JSON Web Tokens

CORS設定

由於我們需要通過請求體的headers傳遞token,所以我們需要對CORS模組進行額外的配置,程式碼如下


// 我們將會通過headers的x-access-token欄位向服務端傳遞token
app.use(cors({
  origin: 'http://127.0.0.1:8080',
  credentials: true,
  methods: ['PUT', 'POST', 'GET', 'DELETE', 'OPTIONS'],
  allowedHeaders: [
    'Content-Type',
    'Content-Length',
    'Authorization',
    'Accept',
    'X-Requested-With',
    'x-access-token']
}))
複製程式碼

環境配置

我們需要下載以下的依賴包, 以及用於加密token的字串secret(可以是一個隨機字串)


npm install --save jsonwebtoken
複製程式碼

獲取token

我們通常通過登陸操作獲取token,服務端生成token後,會交由瀏覽器端管理token

const jwt = require('jsonwebtoken')
const User = require('../models/user')
const bcrypt = require('../util/bcrypt')
const secret = require('../config/index').secret

// ...

login (name, password) {
  return new Promise((resolve, reject) => {
    User.findOne({ name: name }, (err, user) => {
      if (err) return reject(err)
      if (!user) return reject('使用者名稱不存在')
      // bcrypt用於加密的包
      if (!bcrypt.compare(password, user.password)) return reject('使用者名稱或密碼錯誤')
      // 根據id資訊以及secret生成,對應的token,並設定token的過期時間
      const token = jwt.sign({ id: user._id }, secret, {
        expiresIn: 60 * 60 // token過期時間
      })
      // 返回token
      resolve(token)
    })
  })
}
複製程式碼

受保護的介面

有一些API介面,將會受到token的保護,如果請求沒有包含token資訊,請求將會失敗。我們這裡將會封裝一箇中介軟體,幫助我們用來判斷請求是否包含token資訊,以及token資訊是否過期,程式碼如下

const jwt = require('jsonwebtoken')
const secret = require('../config/index').secret

module.exports = function (req, res, next) {
  // 獲取請求的token資訊
  const token = req.body.token || req.query.token || req.headers['x-access-token']
  if (token) {
    // 檢驗token資訊是否過期
    jwt.verify(token, secret, function(err, decoded) {      
      if (err) {
        return res.status(403).json({ code: 'error', error: 'token失效' })    
      } else {
        req.decoded = decoded    
        next()
      }
    })
  } else {
    res.status(403).json({code: 'error', error: '沒有許可權'})
  }
}
複製程式碼

接下來我們將封裝的中介軟體,應用到我們的介面中。在這裡,獲取全部使用者資訊的介面將會收到token的保護,如果不包含token,將會返回403錯誤

const express = require('express')
const router = express.Router()
const UserService = require('../service/user.service')
const AuthenticationToken = require('../middleware/AuthenticationToken')

// AuthenticationToken中介軟體保護/users介面
router.get('/users', AuthenticationToken, (req, res) => {
  UserService.users().then(result => {
    res.status(200).json({code: 'ok', data: result})
  }).catch(error => {
    res.status(500).json({code: 'error', error})
  })
}) 
複製程式碼

相關文章