【單頁面博客從前端到後端】基於 Passport 和 Koa@2 的權限驗證與 DVA 的 Model 設計

基於 JWT 的權限驗證

這裏有一篇文章描述的已經很是詳盡,闡述了 JWT 驗證相比較傳統的持久化 session 驗證的優點,以及基於 angularexpress 驗證的簡單流程。前端

基於Json WebToken的權限驗證node

Passport 專一於用戶驗證 Nodejs 庫

Passport 提供了多種的驗證策略,如:webpack

  • passport-http-bearer - 使用 Bearer tokensHTTP 請求作權限驗證。這個最適合咱們的項目不過了。git

  • passport-local - 本地驗證,普通的登錄驗證,數據庫密碼驗證成功便可。angularjs

此外還有 passport-github , passport-weixin , passport-qq , passport-weibo … ,這些你均可以在 官網 上找到。 github

咱們就採用這種方式來進行權限驗證。web

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.0mongodb

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 來鏈接 MongoDBexpress

(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 本地服務器進行數據模擬,咱們能夠開啓 devServerproyx ,以及開啓 koa 的跨域支持

task/config

config.devServer = {
    hot: true,
    contentBase: path.resolve(__dirname, '../dist'),
    publicPath: '/',
    proxy: {
        "/api/v1": "http://localhost:8082"
    }
}

這樣,前端的任何 /api/v1 下的請求,都會被代理到 http://localhost:80828082 就是 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-sagaeffect 等的用法,能夠參考 文檔

這裏咱們對 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)

惟一須要注意的就是actiontype 屬性了,如 app/doLogin 前綴 app 就是 dva.modelnamespace

dva/createDva.js at master · dvajs/dva · GitHub 中能夠看到,dva 會把 model.namespace 最爲 reducer , effects prefix 拼接

而後咱們就能夠在 LoginComponent 中,監聽登錄的相應事件來調用對應的方法了。

小結

在寫後端的時,不免遇到不少錯誤,咱們可使用 supervisorpm2 來監聽文件變更來自動重啓 nodejs 。鑑於後期咱們會使用 pm2 部署項目。這裏我仍是使用 pm2

server/package.json 中的 scripts 下新增:
"start": "pm2 start bin/www --watch --name blog && pm2 log blog",

相關文章
相關標籤/搜索