diff --git a/Gemfile b/Gemfile index a8bf18746..ac723d666 100644 --- a/Gemfile +++ b/Gemfile @@ -93,3 +93,7 @@ gem 'bulk_insert' gem 'searchkick' gem 'aasm' + +# oauth2 +gem 'omniauth', '~> 1.9.0' +gem 'omniauth-oauth2', '~> 1.6.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9c80af8a9..1bf26bdf4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,7 +110,7 @@ GEM grape-entity (0.7.1) activesupport (>= 4.0) multi_json (>= 1.3.2) - hashie (3.6.0) + hashie (3.5.7) htmlentities (4.3.4) httparty (0.16.2) multi_xml (>= 0.5.2) @@ -164,6 +164,12 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) + omniauth (1.9.0) + hashie (>= 3.4.6, < 3.7.0) + rack (>= 1.6.2, < 3) + omniauth-oauth2 (1.6.0) + oauth2 (~> 1.1) + omniauth (~> 1.9) pdfkit (0.8.4.1) public_suffix (3.0.2) puma (3.12.0) @@ -348,6 +354,8 @@ DEPENDENCIES listen (>= 3.0.5, < 3.2) mysql2 (>= 0.4.4, < 0.6.0) oauth2 + omniauth (~> 1.9.0) + omniauth-oauth2 (~> 1.6.0) pdfkit puma (~> 3.11) rack-cors diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 2524c53d8..731a668bb 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -114,35 +114,6 @@ class AccountsController < ApplicationController end end - def successful_authentication(user) - uid_logger("Successful authentication start: '#{user.login}' from #{request.remote_ip} at #{Time.now.utc}") - # Valid user - self.logged_user = user - # generate a key and set cookie if autologin - - set_autologin_cookie(user) - UserAction.create(:action_id => user.try(:id), :action_type => "Login", :user_id => user.try(:id), :ip => request.remote_ip) - user.update_column(:last_login_on, Time.now) - # 注册完成后有一天的试用申请(先去掉) - # UserDayCertification.create(user_id: user.id, status: 1) - end - - def set_autologin_cookie(user) - token = Token.get_or_create_permanent_login_token(user, "autologin") - cookie_options = { - :value => token.value, - :expires => 1.month.from_now, - :path => '/', - :secure => false, - :httponly => true - } - if edu_setting('cookie_domain').present? - cookie_options = cookie_options.merge(domain: edu_setting('cookie_domain')) - end - cookies[autologin_cookie_name] = cookie_options - logger.info("cookies is #{cookies}") - end - def logout UserAction.create(action_id: User.current.id, action_type: "Logout", user_id: User.current.id, :ip => request.remote_ip) session[:user_id] = nil @@ -183,20 +154,6 @@ class AccountsController < ApplicationController end private - def autologin_cookie_name - edu_setting('autologin_cookie_name') || 'autologin' - end - - def logout_user - if User.current.logged? - if autologin = cookies.delete(autologin_cookie_name) - User.current.delete_autologin_token(autologin) - end - User.current.delete_session_token(session[:tk]) - self.logged_user = nil - end - session[:user_id] = nil - end # type 事件类型 1:用户注册 2:忘记密码 3: 绑定手机 4: 绑定邮箱, 5: 验证手机号是否有效 # 如果有新的继续后面加 # login_type 1:手机类型 2:邮箱类型 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e7b4bdac6..d61aa32ac 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,6 +7,7 @@ class ApplicationController < ActionController::Base include ControllerRescueHandler include GitHelper include LoggerHelper + include LoginHelper protect_from_forgery prepend: true, unless: -> { request.format.json? } @@ -225,12 +226,6 @@ class ApplicationController < ActionController::Base # end end - def start_user_session(user) - session[:user_id] = user.id - session[:ctime] = Time.now.utc.to_i - session[:atime] = Time.now.utc.to_i - end - def user_setup # reacct静态资源加载不需要走这一步 return if params[:controller] == "main" @@ -256,17 +251,6 @@ class ApplicationController < ActionController::Base # User.current = User.find 81403 end - # Sets the logged in user - def logged_user=(user) - reset_session - if user && user.is_a?(User) - User.current = user - start_user_session(user) - else - User.current = User.anonymous - end - end - # Returns the current user or nil if no user is logged in # and starts a session if needed def find_current_user diff --git a/app/controllers/concerns/login_helper.rb b/app/controllers/concerns/login_helper.rb new file mode 100644 index 000000000..e79654958 --- /dev/null +++ b/app/controllers/concerns/login_helper.rb @@ -0,0 +1,69 @@ +module LoginHelper + extend ActiveSupport::Concern + + def edu_setting(name) + EduSetting.get(name) + end + + def autologin_cookie_name + edu_setting('autologin_cookie_name') || 'autologin' + end + + def set_autologin_cookie(user) + token = Token.get_or_create_permanent_login_token(user, "autologin") + cookie_options = { + :value => token.value, + :expires => 1.month.from_now, + :path => '/', + :secure => false, + :httponly => true + } + if edu_setting('cookie_domain').present? + cookie_options = cookie_options.merge(domain: edu_setting('cookie_domain')) + end + cookies[autologin_cookie_name] = cookie_options + Rails.logger.info("cookies is #{cookies}") + end + + def successful_authentication(user) + Rails.logger.info("id: #{user&.id} Successful authentication start: '#{user.login}' from #{request.remote_ip} at #{Time.now.utc}") + # Valid user + self.logged_user = user + + # generate a key and set cookie if autologin + set_autologin_cookie(user) + + UserAction.create(action_id: user&.id, action_type: 'Login', user_id: user&.id, ip: request.remote_ip) + user.update_column(:last_login_on, Time.now) + # 注册完成后有一天的试用申请(先去掉) + # UserDayCertification.create(user_id: user.id, status: 1) + end + + def logout_user + if User.current.logged? + if autologin = cookies.delete(autologin_cookie_name) + User.current.delete_autologin_token(autologin) + end + User.current.delete_session_token(session[:tk]) + self.logged_user = nil + end + session[:user_id] = nil + end + + # Sets the logged in user + def logged_user=(user) + reset_session + if user && user.is_a?(User) + User.current = user + start_user_session(user) + else + User.current = User.anonymous + end + end + + def start_user_session(user) + session[:user_id] = user.id + session[:ctime] = Time.now.utc.to_i + session[:atime] = Time.now.utc.to_i + end +end \ No newline at end of file diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb new file mode 100644 index 000000000..e2eb26a2a --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,20 @@ +class Oauth::BaseController < ActionController::Base + include RenderHelper + include LoginHelper + + skip_before_action :verify_authenticity_token + + private + + def session_user_id + session[:user_id] + end + + def current_user + @_current_user ||= User.find_by(id: session_user_id) + end + + def auth_hash + request.env['omniauth.auth'] + end +end \ No newline at end of file diff --git a/app/controllers/oauth/qq_controller.rb b/app/controllers/oauth/qq_controller.rb new file mode 100644 index 000000000..27ae3d8aa --- /dev/null +++ b/app/controllers/oauth/qq_controller.rb @@ -0,0 +1,9 @@ +class Oauth::QQController < Oauth::BaseController + def create + user = Oauth::CreateOrFindQqAccountService.call(current_user, auth_hash) + + successful_authentication(user) + + render_ok + end +end \ No newline at end of file diff --git a/app/controllers/oauth/wechat_controller.rb b/app/controllers/oauth/wechat_controller.rb new file mode 100644 index 000000000..8649e9b3f --- /dev/null +++ b/app/controllers/oauth/wechat_controller.rb @@ -0,0 +1,11 @@ +class WechatController < Oauth::BaseController + def create + user = Oauth::CreateOrFindWechatAccountService.call(current_user ,params) + + successful_authentication(user) + + render_ok + rescue Oauth::CreateOrFindWechatAccountService::Error => ex + render_error(ex.message) + end +end \ No newline at end of file diff --git a/app/libs/omniauth/strategies/qq.rb b/app/libs/omniauth/strategies/qq.rb new file mode 100644 index 000000000..513257e3c --- /dev/null +++ b/app/libs/omniauth/strategies/qq.rb @@ -0,0 +1,50 @@ +module OmniAuth + module Strategies + class QQ < OmniAuth::Strategies::OAuth2 + option :client_options, { + site: 'https://graph.qq.com', + authorize_url: '/oauth2.0/authorize', + token_url: '/oauth2.0/token' + } + + def request_phase + super + end + + def authorize_params + super.tap do |params| + %w[scope client_options].each do |v| + if request.params[v] + params[v.to_sym] = request.params[v] + end + end + end + end + + uid { raw_info['openid'].to_s } + + info do + { + name: user_info['nickname'], + nickname: user_info['nickname'], + image: user_info['figureurl_qq_1'] + } + end + + extra do + { raw_info: user_info } + end + + def raw_info + access_token.options[:mode] = :query + @raw_info ||= access_token.get('/oauth2.0/me').parsed + end + + def user_info + access_token.options[:mode] = :query + params = { oauth_consumer_key: options.client_id, openid: raw_info['openid'], format: 'json' } + @user_info ||= access_token.get('/user/get_user_info', params: params) + end + end + end +end diff --git a/app/libs/wechat_oauth.rb b/app/libs/wechat_oauth.rb new file mode 100644 index 000000000..ba4baee30 --- /dev/null +++ b/app/libs/wechat_oauth.rb @@ -0,0 +1,13 @@ +module WechatOauth + class << self + attr_accessor :appid, :secret, :scope, :base_url + + def logger + @_logger ||= STDOUT + end + + def logger=(l) + @_logger = l + end + end +end \ No newline at end of file diff --git a/app/libs/wechat_oauth/error.rb b/app/libs/wechat_oauth/error.rb new file mode 100644 index 000000000..ac7f5fddc --- /dev/null +++ b/app/libs/wechat_oauth/error.rb @@ -0,0 +1,14 @@ +class WechatOauth::Error < StandardError + attr_reader :code + + def initialize(code, msg) + super(msg) + @code = code + end + + def message + I18n.t("oauth.wechat.#{code}") + rescue I18n::MissingTranslationData + super + end +end \ No newline at end of file diff --git a/app/libs/wechat_oauth/service.rb b/app/libs/wechat_oauth/service.rb new file mode 100644 index 000000000..35ef8f455 --- /dev/null +++ b/app/libs/wechat_oauth/service.rb @@ -0,0 +1,61 @@ +module WechatOauth::Service + module_function + + def request(method, url, params) + WechatOauth.logger.info("[WechatOauth] [#{method.to_s.upcase}] #{url} || #{params}") + + client = Faraday.new(url: WechatOauth.base_url) + response = client.public_send(method, url, params) + result = JSON.parse(response.body) + + WechatOauth.logger.info("[WechatOauth] [#{response.status}] #{result}") + + if result['errcode'].present? && result['errcode'].to_s != '0' + raise WechatOauth::Error.new(result['errcode'], result['errmsg']) + end + + result + end + + # https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html + # response: + # { + # "access_token":"ACCESS_TOKEN", + # "expires_in":7200, + # "refresh_token":"REFRESH_TOKEN", + # "openid":"OPENID", + # "scope":"SCOPE", + # "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" + # } + def access_token(code) + params = { + appid: WechatOauth.appid, + secret: WechatOauth.secret, + code: code, + grant_type: 'authorization_code' + } + + request(:get, '/sns/oauth2/access_token', params) + end + + # https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Authorized_Interface_Calling_UnionID.html + # response: + # { + # "openid":"OPENID", + # "nickname":"NICKNAME", + # "sex":1, + # "province":"PROVINCE", + # "city":"CITY", + # "country":"COUNTRY", + # "headimgurl": "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0", + # "privilege":[ + # "PRIVILEGE1", + # "PRIVILEGE2" + # ], + # "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" + # + # } + def user_info(access_token, openid) + request(:get, '/sns/userinfo', access_token: access_token, openid: openid) + end +end \ No newline at end of file diff --git a/app/models/open_user.rb b/app/models/open_user.rb new file mode 100644 index 000000000..cedf02edb --- /dev/null +++ b/app/models/open_user.rb @@ -0,0 +1,5 @@ +class OpenUser < ApplicationRecord + belongs_to :user + + validates :uid, presence: true, uniqueness: { scope: :type } +end \ No newline at end of file diff --git a/app/models/open_users/qq.rb b/app/models/open_users/qq.rb new file mode 100644 index 000000000..242693ce5 --- /dev/null +++ b/app/models/open_users/qq.rb @@ -0,0 +1,3 @@ +class OpenUsers::QQ < OpenUser + +end \ No newline at end of file diff --git a/app/models/open_users/wechat.rb b/app/models/open_users/wechat.rb new file mode 100644 index 000000000..046b3e086 --- /dev/null +++ b/app/models/open_users/wechat.rb @@ -0,0 +1,3 @@ +class OpenUsers::Wechat < OpenUser + +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 449a86e8b..9c9dea0ea 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,6 +26,8 @@ class User < ApplicationRecord MIX_PASSWORD_LIMIT = 8 + LOGIN_CHARS = %W(2 3 4 5 6 7 8 9 a b c f e f g h i j k l m n o p q r s t u v w x y z).freeze + has_one :user_extension, dependent: :destroy accepts_nested_attributes_for :user_extension, update_only: true @@ -603,6 +605,15 @@ class User < ApplicationRecord admin? || business? end + def self.generate_login(prefix) + login = prefix + LOGIN_CHARS.sample(8).join('') + while User.exists?(login: login) + login = prefix + LOGIN_CHARS.sample(8).join('') + end + + login + end + protected def validate_password_length # 管理员的初始密码是5位 diff --git a/app/services/oauth/create_or_find_qq_account_service.rb b/app/services/oauth/create_or_find_qq_account_service.rb new file mode 100644 index 000000000..0eb187924 --- /dev/null +++ b/app/services/oauth/create_or_find_qq_account_service.rb @@ -0,0 +1,34 @@ +class Oauth::CreateOrFindQqAccountService < ApplicationService + + attr_reader :user, :params + + def initialize(user, params) + @user = user + @params = params + end + + def call + # 存在该用户 + open_user = OpenUsers::QQ.find_by(uid: params['uid']) + return open_user.user if open_user.present? + + if user.blank? || !user.logged? + # 新用户 + login = User.generate_login('q') + @user = User.new(login: login, nickname: params.dig('info', 'nickname')) + end + + ActiveRecord::Base.transaction do + if user.new_record? + user.save! + + gender = params.dig('extra', 'raw_info', 'gender') == '女' ? 1 : 0 + user.create_user_extension!(gender: gender) + end + + OpenUsers::QQ.create!(user: user, uid: params['uid']) + end + + user + end +end \ No newline at end of file diff --git a/app/services/oauth/create_or_find_wechat_account_service.rb b/app/services/oauth/create_or_find_wechat_account_service.rb new file mode 100644 index 000000000..b59d6a68d --- /dev/null +++ b/app/services/oauth/create_or_find_wechat_account_service.rb @@ -0,0 +1,48 @@ +class Oauth::CreateOrFindWechatAccountService < ApplicationService + Error = Class.new(StandardError) + + attr_reader :user, :params + + def initialize(user, params) + @user = user + @params = params + end + + def call + code = params['code'].to_s.strip + raise Error, 'Code不能为空' if code.blank? + + result = WechatOauth::Service.access_token(code) + + # 存在该用户 + open_user = OpenUsers::Wechat.find_by(uid: result['unionid']) + return open_user.user if open_user.present? + + if user.blank? || !user.logged? + # 新用户 + login = User.generate_login('w') + @user = User.new(login: login, nickname: result['nickname']) + end + + ActiveRecord::Base.transaction do + if user.new_record? + user.save! + + gender = result['sex'].to_i == 1 ? 0 : 1 + user.create_user_extension!(gender: gender) + end + + OpenUsers::Wechat.create!(user: user, uid: result['unionid']) + end + + user + rescue WechatOauth::Error => ex + raise Error, ex.message + end + + private + + def code + params[:code].to_s.strip + end +end \ No newline at end of file diff --git a/config/configuration.yml.example b/config/configuration.yml.example new file mode 100644 index 000000000..f492adb20 --- /dev/null +++ b/config/configuration.yml.example @@ -0,0 +1,19 @@ +defaults: &defaults + oauth: + qq: + appid: 'test' + secret: 'test123456' + wechat: + appid: 'test' + secret: 'test' + scope: 'snsapi_login' + base_url: 'https://api.weixin.qq.com' + +development: + <<: *defaults + +test: + <<: *defaults + +production: + <<: *defaults \ No newline at end of file diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index d173fb9fa..a501cb14f 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,3 +14,8 @@ # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.acronym 'RESTful' # end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'QQ' + inflect.acronym 'OmniAuth' +end \ No newline at end of file diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 000000000..27ade9ed4 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,17 @@ +OmniAuth.config.add_camelization 'qq', 'QQ' + +oauth_config = {} +begin + config = Rails.application.config_for(:configuration) + oauth_config = config.dig('oauth', 'qq') + raise 'oauth qq config missing' if oauth_config.blank? +rescue => ex + raise ex if Rails.env.production? + + puts %Q{\033[33m [warning] qq oauth config or configuration.yml missing, + please add it or execute 'cp config/configuration.yml.example config/configuration.yml' \033[0m} +end + +Rails.application.config.middleware.use OmniAuth::Builder do + provider :qq, oauth_config['appid'], oauth_config['secret'] +end diff --git a/config/initializers/wechat_oauth_init.rb b/config/initializers/wechat_oauth_init.rb new file mode 100644 index 000000000..6c7f849ec --- /dev/null +++ b/config/initializers/wechat_oauth_init.rb @@ -0,0 +1,17 @@ +oauth_config = {} +begin + config = Rails.application.config_for(:configuration) + oauth_config = config.dig('oauth', 'wechat') + raise 'oauth wechat config missing' if oauth_config.blank? +rescue => ex + raise ex if Rails.env.production? + + puts %Q{\033[33m [warning] wechat oauth config or configuration.yml missing, + please add it or execute 'cp config/configuration.yml.example config/configuration.yml' \033[0m} +end + +WechatOauth.appid = oauth_config['appid'] +WechatOauth.secret = oauth_config['secret'] +WechatOauth.scope = oauth_config['scope'] +WechatOauth.base_url = oauth_config['base_url'] +WechatOauth.logger = Rails.logger diff --git a/config/locales/oauth/wechat.zh-CN.yml b/config/locales/oauth/wechat.zh-CN.yml new file mode 100644 index 000000000..12b58c513 --- /dev/null +++ b/config/locales/oauth/wechat.zh-CN.yml @@ -0,0 +1,4 @@ +'zh-CN': + oauth: + wechat: + '40029': '授权已失效,请重新授权' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4e7b19687..46b6c1540 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,8 @@ Rails.application.routes.draw do get 'attachments/download/:id', to: 'attachments#show' get 'attachments/download/:id/:filename', to: 'attachments#show' + get '/auth/qq/callback', to: 'oauth/qq#create' + resources :edu_settings resources :admin scope '/api' do diff --git a/db/migrate/20190821054352_create_open_users.rb b/db/migrate/20190821054352_create_open_users.rb new file mode 100644 index 000000000..f8e0aba4b --- /dev/null +++ b/db/migrate/20190821054352_create_open_users.rb @@ -0,0 +1,14 @@ +class CreateOpenUsers < ActiveRecord::Migration[5.2] + def change + create_table :open_users do |t| + t.references :user + + t.string :type + t.string :uid + + t.timestamps + + t.index [:type, :uid], unique: true + end + end +end