新手问题 Vue + Rails 做前后端分离,请问登录该如何处理呢 (原系统用的 Devise),恳请大佬们提建议

wzbooks23 · 2018年07月10日 · 最后由 a-wing 回复于 2018年07月13日 · 5158 次阅读

Vue + Rails 做前后端分离,Rails 负责 API 这块儿。原系统登录功能用的 Devise,我想在这基础上实现 API 登录,生成 session,记录在 cookie 中,或者用其他 gem 都可以。

想问下大佬们,有什么好的建议吗

建议你目前不分离,先用 vue 把依赖 rails view 的页面改造。完了再考虑下一步

alexneverpo 回复

项目需求我也没办法,😖

我的做法是,用 has_secure_password 自己写登录验证,cancancan + rolify 做权限控制,kong 当 api gateway, login request 登录成功返回一个 jwt token,然后前端利用 token 通信

bobo 回复

谢谢提供思路,我先去了解一下😁

定义一个 controller < Devise::SessionsControlle

...
set_flash_message(:notice, :signed_error)
resource = warden.authenticate!(scope: resource_name, recall: "#{controller_path}#failure")
set_flash_message(:notice, :signed_in) if is_navigational_format?   

@admin_sign_in = sign_in(resource_name, resource)  
...

想起前司用了 Devise,登入界面就没有拆成前后分离,用的 rails view 层

tommy13519261 回复

照大佬的方式操作了 新建了 controller

class SessionsController < Devise::SessionsController
  def create
    set_flash_message(:notice, :signed_error)
    resource = warden.authenticate!(params[:login], params[:password])
    set_flash_message(:notice, :signed_in) if is_navigational_format?
    @admin_sign_in = sign_in(resource_name, resource)
  end
end

routes 也指向了

devise_for :users, controllers: {sessions:"sessions"}

提示错误:

RuntimeError (Invalid strategy):

请问是 controller 里还缺什么吗

pathbox 回复

好像这样也可以诶,我想一想,谢谢提供思路😁

wzbooks23 回复
skip_before_action :require_no_authentication

devise 部分可以保留后端渲染,只对 devise 以外的部分进行了前后端分离。用 devise 登录以后,如果你是用 fetch 进行 API 请求,要在请求的 option 中加上 credentials: 'include',就可以在 API 请求时带上 cookie 和 session 了。

我的看法是:不要用 devise,用户系统是最核心的业务逻辑,怎么可以用第三方 gem?这么核心的逻辑都不愿意写,干嘛不用 wordpress

# https://github.com/bydmm/yuanlimm/blob/master/app/controllers/application_controller.rb#L10
  def current_user
    token = request.env['HTTP_X_TOKEN']
    @current_user ||= User.find_by(token: token)
  end

我和你结构差不多,vue 用 token 授权,通过登录 api 得到 token 存在 localstore 里,其他请求带上就行了

长文预警(贴了很多我项目里的代码)

我也是前后端完全分离
Vue 单页应用和 Rails 5 完全分开放 2 个代码库里(2 个独立的文件夹) 也用了 Devise

登录验证:用 JWT

思路

前端:

Vue 发送 POST 登录请求(带上用户名和密码)
如果用户名和密码对,后端返回 JWT,Vue 获得 JWT 之后就存在 localStorage 里(或者 sessionStorage 或者 cookie 里,看你喜欢咯)
JWT 里的数据带了生成时间,可以判断并过期。
需要身份验证就让 Axios 带上请求头 Authorization

var instance = axios.create({
  baseUrl: 'http://localhost:3000/backend/api/v1'
})
instance.defaults.headers.common['Authorization'] = localStorage.getItem('user-token') || '';

文件路径:src/api/user.js

后端:

验证用户名密码是否正确,如果正确就生成 JWT 并返回回去。

Rails 代码:

文件地址:Gemfile

gem 'devise-jwt', '~> 0.5.6'

文件地址:/app/utils/jwt_token.rb

require 'jwt'

module JwtToken
  ALGORITHM = 'HS256'

  def self.issue(payload)
    JWT.encode(payload, auth_secret, ALGORITHM)
  end
  def self.decode(token)
    JWT.decode(token, auth_secret, true,{ algorithm: ALGORITHM }).first
  end
  def self.auth_secret
    ENV["AUTH_SECRET"] // 这个秘钥很重要,如果不喜欢用环境变量就 bundle exec rails secret 自己生成一个放这里。
    // 比如 e8ffa64262b6928f397c3890a44778958b37709cc18f2b2bf7ae4d27bbf30e2da1948ea14b0b937d543cdbd45ac651c33d30650088180cef891f3e93f051a62f
  end
end

文件路径: config/application.rb

config.autoload_paths += [
  Rails.root.join('app', 'utils'),
]

文件路径 app/controllers/api/v1/application_controller.rb

class Api::V1::ApplicationController < ActionController::Base
  skip_before_action :verify_authenticity_token
  # JWT 验证 (验证 HTTP 请求头里的 Authorization)
  # 部分代码复制自:https://www.sitepoint.com/introduction-to-using-jwt-in-rails/

  protected

  # 强制验证,会报错
  def authenticate_request!
    unless user_id_in_token?
      render json: { errors: ['Not Authenticated'] }, status: :unauthorized
      return
    end
    @current_user = User.find(auth_token['user_id'])
  rescue JWT::VerificationError, JWT::DecodeError
    render json: { errors: ['Not Authenticated'] }, status: :unauthorized
  end

  # 软验证,不会报错
  def current_user
    if user_id_in_token?
      @current_user = User.find(auth_token['user_id'])
    else
      false
    end
  end

  private
  def http_token
    @http_token ||= if request.headers['Authorization'].present?
      request.headers['Authorization'].split(' ').last
    end
  end

  def auth_token
    @auth_token ||= JwtToken.decode(http_token)
  end

  def user_id_in_token?
    http_token && auth_token && auth_token['user_id'].to_i
  end
end

可以看到这些判断 jwt 的代码都写在 ApplicationController 里了。
在其他 Controller 里使用方法如下:
文件路径:app/controllers/api/v1/topic_controller.rb

class Api::V1::TopicController < Api::V1::ApplicationController
  before_action :authenticate_request!, only: %i[create like unlike comment destroy]
  before_action :current_user, only: %i[show]
  # 省略其他不重要代码,注意看这里的 before_action
end
```

文件路径:`app/models/user.rb`
```ruby
  # http://blog.jasonheylon.com/2017/12/01/rails-vue-vuex-jwt-token-auth.html
  def token
    JwtToken.issue(user_id: self.id)
  end
```

文件路径:`app/controllers/api/v1/sessions_controller.rb`
```ruby
  # 登录
  def create
    # 验证参数
    unless (params.has_key?(:phone) || params.has_key?(:password))
      render json: { status_code: '1', msg: '参数不足' }
      return
    end
    if params[:phone] == '' ||  params[:password] == ''
      render json: { status_code: '2', msg: '参数错误' }
      return
    end

    # 拿到用户
    @user = User.where(phone: params[:phone]).first
    unless @user 
      render json: { status_code: '3', msg: '用户不存在' }
      return
    end

    if @user && @user.valid_password?(params[:password])
      render json: @user, serializer: UserSerializer, root: 'user' // 注意这里的 UserSerializer
    else
      render json: { status_code: '4', msg: '用户名或密码错误' }
    end
  end
```

文件路径:`app/serializers/user_serializer.rb`
```ruby
class UserSerializer < ActiveModel::Serializer
  attributes :id, :token, :login, :portrait_url, :followers_count, :following_count, :admin
  #  注意这里的 :token 调用了 User Model 对应的方法,其他属性这里不重要,可忽略
  def admin
    object.admin?
  end

  def portrait_url
    object.portrait
  end

end
```

## Vue 代码
Vue 这边用了一些库,干脆整个文件全部贴出来供参考。
用了 Axios 和 Vuex 和 Vue-Router 等

文件路径 `package.json`
```json
{
  "name": "x",
  "description": "x",
  "version": "1.0.0",
  "author": "x",
  "license": "MIT",
  "private": true,
  "scripts": {
    "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot --port 8081",
    "build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
  },
  "dependencies": {
    "axios": "^0.18.0",
    "bootstrap": "^4.1.1",
    "bootstrap-vue": "^2.0.0-rc.11",
    "jstransformer-verbatim": "^1.1.1",
    "jwt-decode": "^2.2.0",
    "moment": "^2.22.2",
    "qiniu-js": "^2.4.0",
    "tui-editor": "^1.2.3",
    "v-tooltip": "^2.0.0-rc.33",
    "vue": "^2.5.11",
    "vue-i18n": "^8.0.0",
    "vue-markdown": "^2.2.4",
    "vue-router": "^3.0.1",
    "vuelidate": "^0.7.4",
    "vuex": "^3.0.1"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ],
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-env": "^1.6.0",
    "babel-preset-stage-3": "^6.24.1",
    "clean-webpack-plugin": "^0.1.19",
    "cross-env": "^5.0.5",
    "css-loader": "^0.28.7",
    "file-loader": "^1.1.4",
    "html-webpack-plugin": "^3.2.0",
    "node-sass": "^4.9.0",
    "pug": "^2.0.3",
    "pug-loader": "^2.4.0",
    "raw-loader": "^0.5.1",
    "sass-loader": "^6.0.7",
    "style-loader": "^0.21.0",
    "vue-loader": "^13.0.5",
    "vue-template-compiler": "^2.4.4",
    "webpack": "^3.6.0",
    "webpack-dev-server": "^2.9.1"
  }
}

```

登录界面是一个弹窗,写成了组件:          
文件路径:`/src/components/signup_login.vue`             
因为代码长,这里只贴关键代码:             

```javascript
<template lang='pug'>
//省略
</template>
<script>
  import {
    mapGetters,
    mapActions
  } from 'vuex';
  import {
    required,
    minLength,
  } from 'vuelidate/lib/validators'
  import sessionApi from '../api/session'

  export default {
    data: function() {
      return {
        // 省略
      }
    },
    computed: {
      ...mapGetters([
        // 省略
      ])
    },
    methods: {
      ...mapActions([
         // 省略
      ]),
    // 这个 login 是关键方法
      login: function() {
        let data = {
            phone: this.loginPhone,
            password: this.loginPassword
        };
        var self = this;
        sessionApi.signInUser(data).then((result) => { 
          if (result['token']) {
            self.$store.dispatch('logIn', result); // 关键在这里。。登录成功就 dispatch
          }else{
            if(result['status_code'] == '3' || result['status_code'] == '4'){
              alert('用户名或密码错误');
            }
          }
        }).catch((err) => {
          alert(err);
        })
      },
    }
  }
</script>
<style>
// 省略
</style>
```

文件路径:`src/api/session.js`
```javascript
// 注册登录相关
import axios from 'axios'
import baseUrl from './base'

// 这里 baseUrl 其实就只是 http://localhost:3000/backend/api/v1

const URLS = {
  SIGN_IN_API_URL: `${baseUrl}/signin`,
}

export default {
  // 登录  ---- 关键是这里,看这个 signInUser 是上一个代码片段里调用了的
  signInUser (signInData) {
    return axios.post(URLS.SIGN_IN_API_URL, signInData).then((response) => {
      return Promise.resolve(response.data)
    }).catch((error) => {
      return Promise.reject(error)
    })
  },
}
```

文件路径:`src/store/actions.js`
```javascript
  // 登录
  logIn ({commit}, result) {
    var token = result['token'];
    localStorage.setItem('user-token', token);
    localStorage.setItem('currentUser', JSON.stringify(result));
    axios.defaults.headers.common['Authorization'] = token;
    commit('SET_LOGIN_IN', result);
  },
```

文件路径:`src/store/mutations.js`
```javascript
  // 成功登录
  SET_LOGIN_IN(state, payload) {
    state.currentUser = payload;
    state.jwt_token = payload.token;
    state.isLoggedIn = true;
    state.showPopupLoginIn = false;
  },
```

## 最后
我想前后端的代码我都贴的差不多了,应该不缺什么了。
其实总体就是后端怎么生成和验证 JWT,前端怎么保存和发送 JWT

## 参考资料
https://www.sitepoint.com/introduction-to-using-jwt-in-rails/
baurine 回复

好办法,谢谢提供思路😁

geekerzp 回复

谢谢我了解一下😁

gaicitadie 回复

是这样的,原系统已经开发完成且上线了。登录这块儿并非不愿意写,只是想改动尽量小一点,能兼容 Devise 是最好的,不行的话也只能重写了。用 Rails 做前后端分离,我也是第一次,没什么经验,所以想看看有没有更好更合适的方案

1c7 回复

非常感谢大佬提供思路和代码,我研究一下,服务端校验这块儿,感觉 token 确实会比较合适一点。

bydmm 回复

谢谢提供思路,用 token 确实是一个好的解决方案😁

我们这是用 jwt 的,登录时获取 token 和模块权限列表,然后存在 vuex 中,路由跳转时在路由钩子中判断是否有页面权限,有则进入,无则跳转 404 页面

我直接做成 OAuth 的登录了。。。用 doorkeeper 和 devise 对接。。。。。。前端的登录状态管理用 vuex

需要 登录 后方可回复, 如果你还没有账号请 注册新账号