基於 JWT 的許可權驗證
這裡有一篇文章描述的已經非常詳盡,闡述了 JWT
驗證相比較傳統的持久化 session
驗證的優勢,以及基於 angular
和 express
驗證的簡單流程。
Passport 專注於使用者驗證 Nodejs 庫
Passport 提供了多種的驗證策略,如:
-
passport-http-bearer – 使用
Bearer tokens
對HTTP
請求做許可權驗證。這個最適合我們的專案不過了。 -
passport-local – 本地驗證,普通的登陸驗證,資料庫密碼驗證成功即可。
此外還有 passport-github
, passport-weixin
, passport-qq
, passport-weibo
… ,這些你都可以在 官網 上找到。
我們就採用這種方式來進行許可權驗證。
Koa@2 基本環境
首先需要注意的是使用 Koa@2,Node的版本需要 7.X的版本以上,而且啟動時需要加上
--harmony
或者—harmony-async-await
最近Node 8.0
已經上線,我直接採用的是Node v8.0.0
nvm install 8.0.0
nvm alias default 8.0.0
blog/server
基本的目錄結構
server
├─ bin / www # 入口檔案
├─ config # server配置檔案
├─ controller # 控制器資料夾
| └─ user.js
├─ lib
| ├─ auth.js # 認證邏輯
| └─ db.js # 資料庫 連線等
├─ models # Mongoose Models
├─ routes # Koa router
├─ utils # 工具方法
├─ index.js
└─ package.json
我們在入口檔案處 server/bin/www
來連線 MongoDB
(async () => {
// 測試連線 MongoDB
try {
const info = await connect(dbConfig)
console.log(`Success to connect to MongoDB at ${info.host}:${info.port}/${info.name}`)
} catch (err) {
console.error(err)
process.exit()
}
// 開啟服務程式
try {
app.listen(port)
console.log(`Server is running at http://localhost:${port}`)
} catch (err) {
console.error(err)
}
})()
server/lib/db.js
下對應的 connect
方法
exports.connect = function (config) {
return new Promise((resolve, reject) => {
mongoose.connection
.on(`error`, err => reject(err))
.on(`close`, () => console.log(`MongoDB connection closed! `))
.on(`open`, () => resolve(mongoose.connections[0]))
mongoose.connect(`mongodb://${config.host}:${config.port}/${config.database}`, config.options)
})
}
在 server/config/index.js
增加 MongoDB
的配置
const base = {
admin: {
username: `whistleyz`,
password: `admin123`,
email: `whistleyz@163.com`,
level: 51 // >50 超管
}
}
const dev = Object.assign(base, {
db: {
host: `127.0.0.1`,
port: 27017,
database: `fullblog`,
options: {
user: ``,
pass: ``
}
}
})
const prod = Object.assign(base, {})
const env = process.env.NODE_ENV || `development`
const _config = {
development: dev,
production: prod
}
// 資料庫配置
module.exports = _config[env]
由於線上和我們開發甚至是測試環境,配置都會有些許不同,我們可以用
process.env.NODE_ENV
來區分這些配置
實現後端驗證邏輯
新建 server/lib/auth.js
// serialize deserialize user objects into the session
passport.serializeUser((user, done) => done(null, user.username))
passport.deserializeUser(async (username, done) => {
const user = await UserModel.findOne({username})
done(null, user)
})
/**
* 基於 Bearer、Local 的認證方式
* 下面匯出的路由中介軟體走的就是這裡的邏輯
* passport-http-bearer 會自動解析出 headers 中的 token
* https://github.com/jaredhanson/passport-http-bearer/blob/master/lib/strategy.js#L89
*/
passport.use(new BearerStrategy(async (token, done) => {
try {
console.log(token)
const accessToken = await AccessToken.findOne({token}).populate(`user`)
accessToken ? done(null, accessToken.user) : done(null, false, {type: `error`, message: `授權失敗!`})
} catch (err) {
done(err)
}
}))
/**
* 預設從 req.body 或者 req.query 中取出 username, password 欄位
* https://github.com/jaredhanson/passport-local/blob/master/lib/strategy.js#L49
*/
passport.use(new LocalStrategy(async (username, password, done) => {
try {
const user = await UserModel.findOne({username})
if (user && user.validPassword(password)) {
done(null, user)
} else {
done(null, false)
}
} catch (err) {
done(err)
}
}))
// 匯出中介軟體
exports.isBearerAuthenticated = function () {
return passport.authenticate(`bearer`, {session: false})
}
exports.isLocalAuthenticated = function () {
return passport.authenticate(`local`, {session: false})
}
exports.passport = passport
新建 server/routes/api.js
:
const Router = require(`koa-router`)
const User = require(`../controllers/user`)
const { isBearerAuthenticated, isLocalAuthenticated } = require(`../lib/auth`)
const router = new Router()
router.use(async (ctx, next) => {
try {
await next()
} catch (error) {
console.error(error)
ctx.status = 400
ctx.body = {
code: error.code,
message: error.message || error.errmsg || error.msg || `unknown_error`,
error
}
}
})
// 初始化使用者資料
User.seed()
// Auth 認證
router.post(`/auth`, isLocalAuthenticated(), User.signToken)
router.get(`/auth`, isBearerAuthenticated(), User.getUserByToken)
module.exports = router.routes()
那麼我們在 server/controller/user.js
下的處理邏輯久變得簡單:
// LocalStrategy 的中介軟體驗證通過,會把 user 儲存在 req 中
exports.signToken = async function (ctx, next) {
const { user } = ctx.req
// 重新請求 token 需要刪除上一次生成的 token
await TokenModel.findOneAndRemove({user: user._id})
const result = await TokenModel.create({
// md5加密
token: genHash(user.username + Date.now()),
user: user._id
})
ctx.status = 200
ctx.body = {
success: true,
data: result
}
}
// LocalStrategy 的中介軟體驗證Token有效,會把 user 儲存在 req 中
exports.getUserByToken = async function (ctx, next) {
ctx.status = 200
ctx.body = {
success: true,
data: ctx.req.user
}
}
// 當資料庫中user表示空的時候,建立超級管理員
exports.seed = async function (ctx, next) {
const users = await UserModel.find({})
const adminInfo = config.admin
if (users.length === 0) {
const _admin = new UserModel(adminInfo)
const adminUser = await _admin.save()
}
}
我們可以藉助
mongoose
還控制Token
的壽命
比如設定 7 天后過期,expires: 60 * 60 * 24 * 7
到這裡我們的後端邏輯基本實現,為了和前端 webpack-dev-server
本地伺服器進行資料模擬,我們可以開啟 devServer
的 proyx
,以及開啟 koa
的跨域支援
task/config
:
config.devServer = {
hot: true,
contentBase: path.resolve(__dirname, `../dist`),
publicPath: `/`,
proxy: {
"/api/v1": "http://localhost:8082"
}
}
這樣,前端的任何 /api/v1
下的請求,都會被代理到 http://localhost:8082
而 8082
就是 koa
伺服器的監聽埠。
// koa 跨域
const logger = require(`koa-logger`)
const app = new koa()
app.use(kcors())
前端的登陸邏輯實現
實現一個 dva model
在下一篇文章中,我們會深入 dva
的框架核心實現。我們先來看看 dav
的基本使用
新建 src/model/app.js
import { doLogin, getUserByToken } from `../service/app`
import { LocalStorage } from `../utils`
import { message } from `antd`
export default {
namespace: `app`,
state: {
isLogin: false,
user: null
},
subscriptions: {},
effects: {
*checkToken({next}, {call, put}){
const Token = LocalStorage.getItem(`token`)
if (Token) {
yield put({type: `loginSuccess`})
} else {
message.error(`你還沒有登陸哦!`)
}
},
*doLogin({payload}, {call, put}){
try {
const { success, data } = yield call(doLogin, payload)
if (success) {
LocalStorage.setItem(`token`, data.token)
yield put({type: `requireAuth`})
}
} catch (err) {
message.error(`授權失敗!`)
yield put({type: `authErr`})
}
},
*getUserByToken({}, {call, put}){
try {
const { success, data } = yield call(getUserByToken)
if (success) {
yield put({type: `authSuccess`, payload: data})
}
} catch (err) {
message.error(err.message)
yield put({type: `authErr`})
}
}
},
reducers: {
loginSuccess(state){
return {
...state, isLogin: true
}
},
authErr(state){
return {
...state, isLogin: false, user: null
}
},
authSuccess(state, {payload}){
return {
...state, user: payload
}
}
}
}
對於
redux-saga
的effect
等的用法,可以參考 文件
這裡我們對 localStorage
做了一次封裝,看了原始碼相信你就知道目的是什麼了:
/**
* src/utils/localStorage.js
* Custom window.localStorage
*/
const STORE_PREFIX = `blog`
export function getItem (key) {
return window.localStorage.getItem(STORE_PREFIX + `-` + key)
}
export function setItem (key, value) {
window.localStorage.setItem(STORE_PREFIX + `-` + key, value)
}
export function removeItem (key) {
window.localStorage.removeItem(STORE_PREFIX + `-` + key)
}
封裝 src/utils/request.js
import fetch from `dva/fetch`
import * as LocalStorage from `./localStorage`
const URL_PREFIX = `/api/v1`
const TOKEN_NAME = `token`
function checkStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
}
const error = new Error(response.statusText);
error.response = response;
throw error;
}
/**
* Requests a URL, returning a promise.
*
* @param {string} url The URL we want to request
* @param {object} [options] The options we want to pass to "fetch"
* @return {object} An object containing either "data" or "err"
*/
export default function request(url, options) {
options = Object.assign({
headers: new Headers({
`Content-Type`: `application/json`
})
}, options)
return fetch(URL_PREFIX + url, options)
.then(checkStatus)
.then(res => res.json())
.then(data => data)
}
/**
* Request width token
* @param {[type]} url
* @param {[type]} options
* @return {[type]}
*/
export function requestWidthToken (url, options) {
const TOKEN = LocalStorage.getItem(TOKEN_NAME)
options = Object.assign({
headers: new Headers({
`Content-Type`: `application/json`,
`Authorization`: `Bearer ${TOKEN}`
})
}, options)
return request(url, options)
}
dva/fetch
直接匯出了fetch
fetch
的用法很簡單,參考 github地址
這裡我們把 url 的 prefix、token name 提取出來用作常量儲存,以便於我們修改,最好的方法是提取出來用一個檔案儲存
元件與model的通訊
還記得我們的展示元件嗎,現在我們讓它 connect
到我們的 model
import { connect } from `dva`
const { Header, Content, Footer } = Layout
const { HeaderRight } = HeaderComponent
const App = ({children, routes, app, doLogin}) => {
const { isLogin, user } = app
return (
<Layout>
<Header>
<HeaderComponent routes={routes}>
{isLogin ? <HeaderRight user={user} /> : <LoginComponent doLogin={doLogin} app={app} /> }
</HeaderComponent>
</Header>
...
)
}
function mapStateToProps ({app}, ownProps) {
return {
app
}
}
function mapDispatchToProps (dispatch) {
return {
doLogin({username, password}){
dispatch({type: `app/doLogin`, payload: {username, password}})
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(App)
唯一需要注意的就是action
的 type
屬性了,如 app/doLogin
字首 app
就是 dva.model
的 namespace
從 dva/createDva.js at master · dvajs/dva · GitHub 中可以看到,
dva
會把model.namespace
最為reducer
,effects
的prefix
拼接
然後我們就可以在 LoginComponent
中,監聽登陸的相應事件來呼叫對應的方法了。
小結
在寫後端的時,難免遇到很多錯誤,我們可以使用 supervisor
、pm2
來監聽檔案變動來自動重啟 nodejs
。鑑於後期我們會使用 pm2
部署專案。這裡我還是使用 pm2
在 server/package.json
中的 scripts
下新增:"start": "pm2 start bin/www --watch --name blog && pm2 log blog",