이 글에서는 OmniAuth 젬(gem)을 이용해서 주요 소셜 서비스를 대상으로 한 외부 인증 방법을 설명한다. 다른 글을 보면 Devise에 OmniAuth를 추가하는 방식을 많이 소개하고 있는데 이 글에서는 Devise를 사용하지 않고 구현하는 법을 소개한다.
셋업
Gemfile

gem 'omniauth'
gem 'omniauth-twitter'
gem 'omniauth-facebook'
gem 'omniauth-google-oauth2'
# gem 'omniauth-kakao'
gem 'figaro'
여기서 figaro 젬은 환경 변수 관리를 위해 추가했으며 사용법은 잠시 후에 설명한다.
OmniAuth 미들웨어 초기화 vi config/initializers/omniauth.rb

OmniAuth.config.full_host = Rails.env.production? ? 'http://zoolu.co.kr' : 'http://localhost:3000'

Rails.application.middleware.use OmniAuth::Builder do
  provider( :twitter, Figaro.env.twitter_consumer_key, Figaro.env.twitter_consumer_secret )
  provider( :facebook, Figaro.env.facebook_app_id, Figaro.env.facebook_app_secret )
  provider( :google_oauth2, Figaro.env.google_client_id, Figaro.env.google_client_secret)
  # provider( :kakao, Figaro.env.kakao_client_id )
end
OmniAuth는 웹 서버(Nginx, 아파치 등)와 앱 서버(Rails) 사이에서 동작하는 랙 미들웨어의 일종이다. 레일즈에서 사용하기 위해서는 위와 같이 미들웨어로 사용할 것을 명시적으로 지정해야 한다. 위 코드에서 Figaro.env.xxx.yyy 부분은 소셜 서비스에서 발급받은 키나 토큰값이 저장된다.
figaro 설치 figaro install 에 의해 config/application.yml이 자동으로 생성된다. 인증 키 등록 vi config/application.yml

GOOGLE_CLIENT_ID: '9a8sfuas8d'
GOOGLE_CLIENT_SECRET: 'fa34r98aha'

TWITTER_CONSUMER_KEY: 'dfasdfuas0'
TWITTER_CONSUMER_SECRET: 'xhvuir90a5'

FACEBOOK_APP_ID: 'p23rf89asf'
FACEBOOK_APP_SECRET: 'xcfadsf8ad'

# KAKAOTALK_CLIENT_ID: 'a92rhafsad'
이 파일에 각 소셜 서비스에서 발급받은 값을 기록한다. 이 파일에 지정한 값은 figaro 젬을 통해 접근할 수 있는데 가령 Figaro.env.google_client_id는 '9a8sfuas8d'을 리턴하는 식이다. 참고로 주요 서비스별 프로젝트 등록 및 인증 키 발급받는 곳은 아래 리스트를 참조한다.
세션 만료 시간 설정 vi config/session_store.rb

Rails.application.config.session_store :cookie_store, key: '_zoolu_session', expire_after: 12.hours
expire_after 설정에 의해 세션(session) 유효 시간이 12시간으로 제한된다. 우리는 로그인 정보를 세션에 저장할 것이므로 로그인 지속 시간도 최대 12시간만 유지된다.
current_user 헬퍼 vi application_controller.rb

def current_user
  if session[:user_token].present?
    @current_user ||= User.find_by(token: session[:user_token])
    session.clear if @current_user.nil?
    @current_user
  end
end

helper_method :current_user
current_user는 현재 로그인된 사용자가 있으면 그 사용자를 찾아서 리턴하고 없으면 nil을 리턴한다. 마지막 라인은 current_user를 helper로 등록함으로써 뷰(view)에서도 사용하려는 조치이다. 이 헬퍼는 User 모델을 필요로 하므로 아래와 같이 생성한다.
rails g model user email:string:index token:string:index
rake db:migrate
가입(Sign-up)
가입 버튼 vi application.html.erb (또는 _header.html.erb 등의 파일)

<%= link_to '가입(Sign-up)', new_user_path %>
위 링크는 가입 페이지(views/users/new.html)로 유도하기 위한 링크이다. 이 링크가 동작하기 위해서는 라우트 설정과 컨트롤러가 필요하다.
vi config/routes.rb

resources :users
rails g controller users
가입 페이지와 소셜 링크 vi app/views/users/new.html.erb

<%= link_to '트위터로 가입하기', '/auth/twitter' %>
<%= link_to '페이스북으로 가입하기', '/auth/facebook' %>
<%= link_to '구글로 가입하기', '/auth/google_oauth2' %>
<%#= link_to '카카오로 가입하기', '/auth/kakao' %>
이제 가입 페이지에서 위 링크 중 하나를 클릭하면 해당 소셜 서비스의 로그인 인증 페이지로 이동하게 된다. 원래대로 하면 링크 클릭 시 http://localhost:3000/auth/:provider 형태의 URL이 발생하지만 OmniAuth 미들웨어가 이 요청을 가로채서 :provider에 해당하는 외부 인증 URL로 리다이렉션을 수행한다.
콜백 처리 외부 인증이 성공하게 되면 보통 http://localhost:3000/auth/:provider/callback 형태로 콜백 요청이 외부 사이트에서 우리 사이트로 전달된다. 이때 다시 한 번 OmniAuth 미들웨어가 개입을 해서 결과값을 env['omniauth.auth']에 정렬을 한 후 레일즈로 넘기게 된다. 우리는 이 콜백 요청을 SessionsController#create로 전달해서 후속 작업을 수행한다.
vi config/routes.rb

get 'auth/:provider/callback',  to: 'sessions#create'
get 'auth/failure',             to: redirect('/')
rails g controller sessions create
vi app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def create
    # raise env["omniauth.auth"].to_yaml
    # env["omniauth.auth"] = {uid: 'xxx', provider: 'xxx', extra: {}, info: {image: 'xxx', email: 'xxx', name: 'xxx'} }

    @auth = Authentication.authenticate!(env['omniauth.auth'])
    if @auth.registered?
      ...
    else
      render 'new'
    end
  end
end
env["omniauth.auth"]에는 외부 사이트에서 보낸 인증 관련 정보(사용자 이름, 이 메일, 사진 등)가 망라되어 있다. 우리는 이 정보에서 필요한 부분을 발췌하여 Authentication 모델에 저장을 한다. 현 단계는 가입 단계이므로 Authentication.authenticate!()에서는 registered 필드를 false로 셋팅할 것이며 따라서 위 조건문은 app/views/sessions/new.html.erb을 렌더링한다.
Authentication#authenticate!() 외부 인증을 통과한 사용자는 Authentication 레코드를 생성한다. 만약 기존 레코드와 중복이면 이 사용자는 이미 기존에 등록된 사용자라고 간주하고 해당 레코드를 리턴한다. 등록 시 registered 필드는 false로 설정해 놓고 있다가 다음 단계인 이메일 인증을 통과하면 true로 변경하여 가입 절차를 완료한다.
rails g model authentication user:references uid provider registered:boolean
vi app/models/authentication.rb

class Authentication < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :uid, :provider
  validates_uniqueness_of :uid, scope: :provider

  def self.authenticate!(omniauth)
    auth = self.find_or_create_by(provider: omniauth.provider, uid: omniauth.uid) do |auth|
      auth.registered = false
      # auth.name = omniauth.info.name
      # auth.nickname = omniauth.info.nickname
      # auth.image = omniauth.info.image
    end
  end
end
사용자 정보(이메일 등) 수집 vi app/views/sessions/new.html.erb

<h1>First time user with <%= @auth.provider %>?</h1>
<%= form_tag users_path do  %>
  <%= email_field_tag :email, nil, placeholder: "Enter your email" %>
  <%= hidden_field_tag :auth_id, @auth.id %>
  <%= submit_tag 'Register' %>
<% end %>
이곳에서 이메일이나 이름 등 필요한 사용자 정보를 입력 받는다. 다음 단계에서 이메일을 통한 확인 절차가 수행되므로 이메일은 필수적으로 요구된다. 'Register' 링크를 누르면 UsersController#create로 이동한다.
본인 확인 메일 발송 다음 단계는 수집된 이메일로부터 새로운 사용자를 만들고 이 사용자에게 본인 확인 메일을 발송하는 과정이다. 아래 코드 중 이메일 조회를 통해 특정 user가 발견되는 경우는 기존 사용자가 다른 종류의 소셜 인증을 통해 가입을 시도한다는 의미가 된다. 이와 같이 동일한 유저가 멀티 소셜 인증을 사용하는 경우를 지원하기 위해서 User has_many :authentications 관계가 필요하다. auth.associate_with(user)는 실제 어쏘시에이션이 수행되는 루틴이다. 아래 코드를 보면 개발 환경에서는 이메일을 발송하는 과정을 생략하고 바로 사용자 확인 메일 응답이 처리되는 곳(Sessions#update)로 건너 뛰도록 처리하였다. rails g controller users
vi app/controllers/users_controller.rb

class UsersController < ApplicationController
  def create
    user = User.find_or_create_by(email: params[:email])
    auth = Authentication.find(params[:auth_id])
    auth.associate_with(user)

    if Rails.env.production?
      # MailerWorker.perform_async(user.id, auth.registration_token)
      # redirect_to root_url, notice: "Email sent with registration instructions."
    else
      # do not send email, and direct forward to sessions#update
      redirect_to registration_path(user_id: user.id, token: auth.registration_token)
    end
  end
end
vi app/models/user.rb

class User < ActiveRecord::Base
  has_many :authentications, dependent: :destroy
  validates :email, uniqueness: true, presence: true
end
vi config/routes.rb

...
# get '/sessions/new', to: 'sessions#new' # this exists only for testing.
get 'sessions/:user_id/:token', to: 'sessions#update',  as: 'registration'
...
vi app/models/authentication.rb

...
def associate_with(user)
  # begin
  #   token = SecureRandom.urlsafe_base64
  # end while Authentication.exists?(registration_token: token)

  self.update_attributes(
    user_id: user.id,
    registration_token_sent_at: Time.now,
    registration_token: SecureRandom.urlsafe_base64 # token
  )
end
...
현재 authentications 테이블에는 registration_token_sent_at과 registration_token 필드가 없으므로 아래와 같이 추가한다.
rails g migration add_registration_token_to_authentications registration_token:string registration_token_sent_at:datetime
가입 절차 마무리 vi app/controllers/sessions_controller.rb
사용자가 확인 이메일이 도착하면 해당 사용자를 정식으로 등록하면서 가입 절차를 종료한다.

class SessionsController < ApplicationController
  def update
    if Authentication.register!(params[:token], params[:user_id])
      redirect_to root_url, notice: "Thank you for registering. Please sign in to continue."
    else
      redirect_to root_url, notice: "Registration has expired"
    end
  end
end
vi app/models/authentication.rb

def self.register!(token, user_id)
  auth = self.find_by(registration_token: token, user_id: user_id, registered: false)
  if auth.present?
    if auth.registration_token_sent_at >= 24.hours.ago
      auth.update_attribute(:registered, true)
    else auth.registration_token_sent_at < 24.hours.ago
      auth.delete
    end
  end
  auth
end
로그인(Sign-in)
로그인 링크 vi application.html.erb (또는 _header.html.erb 등의 파일)

<% unless current_user %>
  <%= link_to '트위터 로그인', '/auth/twitter' %>
  <%= link_to '페이스북 로그인', '/auth/facebook' %>
  <%= link_to '구글 로그인', '/auth/google_oauth2' %>
  <%#= link_to '카카오 로그인', '/auth/kakao' %>
<% end %>
current_user의 존재 유무로 로그인 상태를 판별하며, 존재 하지 않는 경우에만 로그인 링크를 제공한다. 위 링크 중 하나를 클릭하면 해당 소셜 서비스의 로그인 인증 페이지로 이동한 후 외부 인증이 성공하게 되면 http://localhost:3000/auth/:provider/callback 형태로 콜백 요청이 외부 사이트에서 우리 사이트로 전달된다. 우리는 이 콜백 요청을 SessionsController#create에서 처리한다.
로그인 인증 및 저장 vi app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def create
    @auth = Authentication.authenticate!(env['omniauth.auth'])
    if @auth.registered?
      authenticated_user = @auth.user
      authenticated_user.update_login_status(@auth, request.remote_ip)
      session[:user_token] = authenticated_user.token
      redirect_to root_path, notice: "Welcome!!!"
    else
      render 'new'
    end
  end
end
지금 단계는 이미 가입된 사용자에 대한 인증이므로 Authentication.authenticate!()를 통해 등록된 사용자를 찾아낸다. 사용자를 찾으면 로그인 정보(시간, IP 주소 등)와 토큰을 새로 갱신한다. 갱신된 토큰은 세션(session) 해시에 저장하여 current_user 헬퍼가 로그인 상태를 판별하는데 이용한다.
vi app/models/user.rb

class User < ActiveRecord::Base
  def update_login_status(auth, remote_ip)
    if self.token.nil? || (self.current_sign_in_ip != remote_ip)
      begin
        token = SecureRandom.urlsafe_base64
      end while User.exists?(token: token)
    else
      token = self.token
    end

    self.update_attributes(
      last_sign_in_at: self.current_sign_in_at,
      current_sign_in_at: Time.now,
      sign_in_count: (self.sign_in_count || 0) + 1,
      last_sign_in_ip: self.current_sign_in_ip,
      current_sign_in_ip: remote_ip,
      token: token,
      # image_url: auth.image,
      # name: auth.name,
      # nickname: auth.nickname
    )
  end
end
위 내용은 로그인이 발생할 때 마다 시간, IP, 횟수등을 갱신하는 예를 보여준다. 필요한 경우에는 auth에 저장된 사용자 정보를 카피할 수 도 있는데 이 때는 Authentication.anthenticate!()에서 관련 정보를 미리 저장해 놓아야 한다. 사용자마다 고유하게 할당하는 토큰값은 최초 로그인시 한번 설정한 후, 그 이후로는 로그인 위치가 변경될 때 마다 갱신하는 것으로 했다.
위 코드가 동작하기 위해서는 users 테이블에 아래 칼럼이 필요하다.
rails g migration add_login_status_to_users last_sign_in_at:datetime current_sign_in_at:datetime last_sign_in_ip current_sign_in_ip sign_in_count:integer
로그아웃(Sign-out)
로그아웃 링크 vi application.html.erb (또는 _header.html.erb 등의 파일)

<% unless current_user %>
  <%= link_to '트위터 로그인', '/auth/twitter' %>
  <%= link_to '페이스북 로그인', '/auth/facebook' %>
  <%= link_to '구글 로그인', '/auth/google_oauth2' %>
  <%#= link_to '카카오 로그인', '/auth/kakao' %>
<% else %>
  <%= link_to '로그 아웃', '/logout' %>
<% end %>
라우팅 셋업 vi config/routes.rb

get 'logout', to: 'sessions#destroy', as: 'logout'
로그아웃 처리 vi app/controllers/sessions_controller.rb

def destroy
  session.clear # session[:user_token] = nil
  redirect_to root_url, notice: 'Bye for now. Come back soon.'
end
탈퇴(Delete account)
탈퇴는 UsersController#destroy에서 처리한다.
vi app/controllers/users_controller.rb

def destroy
  if params[:user_id].to_i == current_user.id
    current_user.destroy
    session.clear
    redirect_to root_url, notice: 'Bye for now! Come back soon!'
  else
    redirect_to root_url, notice: 'You are not authorized for the job.'
  end
end
로그인 상태에 따른 Root path(Conditional routing)

root(to: 'users#show', constraints: lambda do |r|
    r.env['rack.session']['user_token']
  end, as: :authenticated_root
)
root to: 'welcome#index'
위의 코드는 root_path가 login 상태에 따라 다르게 적용되는 예를 보인 것이다. 로그아웃 상태에서는 welcome#index가 적용되지만, 로그인이 되었을 경우에는 root_path는 users#show로 동작한다. 위에서 lambda 블럭에 전달되는 r은 Rack::Request의 인스턴스 오브젝트로서 env[] 해시에 접근이 가능하다. r.env['rack.session']은 레일즈의 session[]에 그대로 전달되기 때문에 결과적으로 session['user_token']에 접근이 가능해졌다.