레일스 애플리케이션에 OpenID 인증 추가하기 by thinkr

루비온레일스로 새 애플리케이션을 시작하다 보면 늘 성가신 부분 중 하나가 바로 인증이 아닐까. 물론 인증과 관련하여서는 이미 여러 가지 좋은 플러그인들이 나와 있어 그냥 플러그인만 장착하면 그만 아니냐고도 말할 수도 있지만, 사실 인증 처리 만큼 애플리케이션마다 요구사항이 달라지는 부분도 없으며, 그게 바로 레일스가 사용자 인증 처리 부분을 레일스 core에 포함시키지 않는 이유이기도 하다.

 

레일스 애플리케이션에서 사용하는 인증 플러그인 중 가장 보편적인 것은 아직까지는 뭐니뭐니해도 restful_authentication 일 것이다. 여기서는 restful_authenticaion을 통한 통상적인 로그인 방식의 인증과 함께 OpenID 인증도 함께 사용하는 방법을 소개하기로 한다.

 

레일스 애플리케이션에 OpenID 인증을 적용하는 데 관하여는 이미 엄청나게 많은 자료들이 나와 있다. (특히 aproxacs님의 스프링노트 참조). 이 글이 기존의 글들과 조금 다른 점이 있다면 통상적인 ID/패스워드 방식의 로그인과 OpenID 인증 방식을 함께 사용할 경우 생길 수 있는 문제점들을 조금 더 고민해 보고 해법을 시도해 보았다는 것 정도가 될 것이다. 예제는 레일스 2.3.2 버전을 기준으로 하였으며 restful_authentication 플러그인 등 몇 가지 플러그인은 이미 설치했다고 가정하였으니, 플러그인의 설치와 관련된 파일은 각 플러그인의 README 파일을 참조하자.

 

참고로 레일스 2.3에 새로 추가된 템플릿 기능을 사용하여 다음과 같이 새 레일스 애플리케이션을 시작해도 된다.

 

  1. $ rails openid_demo -m /path/to/basic.rb

 

이 예제에서 사용한 템플릿 파일(basic.rb)은 다음과 같다. 여기서 auto_migrations는 필자가 주로 사용하는 방법일 뿐이며, 반드시 필요한 부분은 아니다.

 

  1. # Install plugins
    plugin 'restful-authentication',  :git => 'git://github.com/technoweenie/restful-authentication.git'
    plugin 'open_id_authentication',  :git => 'git://github.com/rails/open_id_authentication.git'
    plugin 'auto_migrations',         :git => 'git://github.com/pjhyett/auto_migrations.git'

    # Setting the plugins
    generate :authenticated, "user sessions --include-activation"
    generate :forgot_password,  "password user"

    rake "open_id_authentication:db:create
    route "map.open_id_complete 'session', :controller => 'sessions', :action => 'create', :requirements => { :method => :get }"

    # Make empty schema.rb for auto_migration
    file "db/schema.rb" <<-CODE
    ActiveRecord::Schema.define() do
    end
    CODE

 

User 모델에 identity_url 필드 추가하기

오픈ID 인증을 위해 필요한 최소한의 필드는 User 객체에 identity_url 필드를 추가하는 것이다. User 모델의 attr_accessor 에도 :identity_url 을 추가하는 것도 잊지 말자.

 

  1.     t.column :identity_url, :string

 

관련 뷰 파일 수정하기

우선 쉬운 것부터 먼저 하자. open_id_authentication 플러그인 README 파일을 참조하여 관련 뷰 파일을 수정하자. 노파심에서 하는 말이지만, 여기 소개된 예제는 그저 하나의 구현 참조일 뿐 반드시 이대로 해야 할 필요는 전혀 없다. TMTOWTDI!

 

  1. <p>
      <label for="openid_identifier">OpenID:</label>
      <%= text_field_tag "openid_identifier" %>
    </p>

 

Session 컨트롤러 수정하기

마찬가지로 open_id_authentication 플러그인 README 파일을 참조하여 session 컨트롤러를 대략 다음과 같은 식으로 수정하자. 이 때 유의할 것은 꼭 여기에 나와 있는 코드 대로 할 필요는 없다는 것이다. 무엇보다도 OpenID의 원리와 spec을 이해하고, 그 기준에 맞게 프로그래밍하는 것이 중요하다. 여기서 open_id_authentication 메서드에 어떠한 인수도 넘기지 않았음에 유의하자. 물론 params[:openid_identifier] 같은 인수를 명시적으로 넘겨도 되지만, open_id_authentication 라이브러리 속에서 자동으로 params 값을 받는 로직이 있기 때문에 굳이 명시적으로 넘길 필요가 없기 때문이다. 더 자세한 것은 open_id_authentication 플러그인의 소스코드를 참조. 또 한가지. 아래 코드 속에서 @user를 로컬변수로 두지 않고 굳이 인스턴스 변수로 둔 이유는 향후 다른 기능을 추가할 때 조금 더 편리하기 때문이다.

 

  1.   def create
        logout_keeping_session!
        if using_open_id?
          open_id_authentication
        else
          password_authentication(params[:name], params[:password])
        end
      end

    protected

      def open_id_authentication
        authenticate_with_open_id(nil, :required => [:nickname, :email]) do |result, identity_url, registration|
          if result.successful?
            @user = User.find_or_initialize_by_identity_url(identity_url)
            if @user.new_record?
              @user.login = registration['nickname']
              @user.email = registration['email']
              @user.identity_url = identity_url
              @user.save(false) # skip validation
            end
            self.current_user = @user
            successful_login
          else
            failed_login result.message
          end
        end
      end

      def password_authentication(name, password)
        if self.current_user = User.authenticate(params[:name], params[:password])
          successful_login
        else
          failed_login "Sorry, that username/password doesn't work"
        end
      end

      def successful_login
        flash[:notice] = "Logged in successfully"
        redirect_back_or_default('/')
      end

      def failed_login(message = "Authentication failed.")
        flash[:error] = message
        redirect_to login_path
      end

 

개인정보 변경하기

이것으로 기본적인 구현은 모두 끝이 났다. 이제 바로 사용하면 된다. 간단하다. 그렇지만 무언가 부족한 부분이 보인다. 예를 들면, 어떤 사용자가 OpenID와 기존 로그인 방식을 병행하여 로그인할 수도 있고, 또 오픈ID로 로그인한 사용자가 자신의 개인정보를 변경할 수도 있다. 또 오픈아이디 사용자와 기존 ID 사용자 간에 ID가 충돌할 경우도 있을 수 있다. 지금부터는 이런 경우에 대해 고려해 보자.

 

우선 간단한 것부터 해보자. 사용자가 자신의 개인정보를 수정할 수 있게 해보자. edit 폼을 만들고 edit와 update 액션을 구현하는 일은 통상적이며 특별한 것이 없다. 다음은 User 컨트롤러의 해당 액션들의 구현 예이다. 로그인 기반이니 login_required를 before_filter로 잡아줘야 함을 잊지 말자. edit 액션의 뷰의 코드는 생략한다.

 

  1.   def edit
        @user = User.find(current_user.id)
      end
     
      def update
        @user = current_user
        if @user.update_attributes(params[:user])
          redirect_to root_path
        else
          render :action => 'edit'
        end
      end

 

한가지 유의할 점은 유효성 검증과 관련된 부분이다. openid를 사용할 경우 별도의 password가 필요 없으며, 따라서 restful_authentication에 들어 있는 password validation과의 충돌이 일어난다. User 모델 객체에서 다음과 같은 식으로 이 부분을 오버라이드해 주면 한다. 여기서는 그냥 간단하게 identity_url이 있는 경우에는 비밀번호 검사를 건너뛰는 걸로 처리하였다.

 

  1.   protected
       
        def password_required?
          identity_url.blank? && (crypted_password.blank? || !password.blank?)
        end

 

Signup 시에 부가정보 받기

오픈ID를 사용할 경우 인증, 즉 자기 스스로 A라고 주장하는 사람이 진짜 A가 맞는지에 대한 검증은 오픈iD 인증으로 끝이 난다. 그리고 오픈ID 인증 시에 인증된 사용자의 등록정보를 넘겨받을 수 있기 때문에 이 정보를 내 서비스에서도 사용할 수 있다. 그렇지만 경우에 따라서는 등록정보 외에 부가적으로 필요한 정보들이 있을 수 있고, 이런 정보들을 회원 가입 시에 추가로 받고자 할 경우가 있다. 여기서는 이름과 휴대폰정보를 부가적으로 받기로 하자. 

 

또 한가지 고려할 사항은 ID가 중복될 가능성이다. 인증 방식으로 오픈ID만 사용하든 아니면 오픈ID와 기존 로그인 방식을 병행하여 사용하든 결국 사용자에게는 고유한 ID 개념이 있어야 하며, 오픈 ID 인증인 경우는 통상적으로 오픈ID의 별명(nickname)을 로그인ID로 사용하게 된다. 그런데 그 별명을 누군가 다른 사람이 이미 자신의 로그인ID로 사용하는 경우가 있을 수 있다. 레일스에서 이 상황은 액티브레코드의 유효성 검증(validation) 처리의 영역에 속한다.

 

우선 가입 시에 부가 정보를 추가할 수 있도록 코드를 개선해 보자. User 모델에 mobile 이란 항목을 하나 추가했다고 가정한다. (mobile 필드를 User모델의 attr_accessor 항목에 추가하는 것도 잊지 말자). 맨 먼저 해야할 일은 Sessions 컨트롤러의 open_id_authentication  메서드를 다음과 같이 약간 변경하여 신규 오픈ID인 경우에는 부가 정보입력 폼을 띄워 부가정보를 입력할 수 있도록 하는 것이다. 

 

  1.   def open_id_authentication
        authenticate_with_open_id(nil, :required => [:nickname, :email]) do |result, identity_url, registration|
          if result.successful?
            @user = User.find_or_initialize_by_identity_url(identity_url)
            if @user.new_record?
              @user.login = registration['nickname']
              @user.email = registration['email']
              @user.identity_url = identity_url
              render :template => 'users/new'
            else
              self.current_user = @user
              successful_login
            end 
          else
            failed_login result.message
          end
        end
      end

 

여기서는 편의상 별도의 폼을 만들지 않고 기존의 new 폼을 사용하는 걸로 하였으며,  오픈ID로 로그인하는 경우에는 비밀번호 입력 부분이  폼 상에서 표시되지 않도록 처리하였다. new폼에서 identity_url을 히든 필드로 넘기고 있음에 유의하자. 이 필드가 있어야 User 객체가 저장될 때 password 유효성 검증을 skip할 수 있기 때문이다.

 

  1. <% form_for :user, :url => users_path do |f| -%>

    <%= f.hidden_field :identity_url %>

    <p><%= label_tag 'login' %><br/>
    <%= f.text_field :login %></p>

    <p><%= label_tag 'email' %><br/>
    <%= f.text_field :email %></p>

    <p><%= label_tag 'name' %><br/>
    <%= f.text_field :name %></p>

    <p><%= label_tag 'mobile' %><br/>
    <%= f.text_field :mobile %></p>

    <% if @user.identity_url.blank? %>
        <p><%= label_tag 'password' %><br/>
        <%= f.password_field :password %></p>

        <p><%= label_tag 'password_confirmation', 'Confirm Password' %><br/>
        <%= f.password_field :password_confirmation %></p>
    <% end %>

    <p><%= submit_tag 'Sign up' %></p>
    <% end -%>

 

마지막으로 User 컨트롤러의 create 액션을 다음과 같이 약간 변경하여, current_user 세션 셋팅을 처리해 주면 된다.

 

  1.   def create
        logout_keeping_session!
        @user = User.new(params[:user])
        success = @user && @user.save
        if success && @user.errors.empty?
          self.current_user = @user
          redirect_back_or_default('/')
          flash[:notice] = "Thanks for signing up!  We're sending you an email with your activation code."
        else
          flash[:error]  = "We couldn't set up that account, sorry.  Please try again, or contact an admin (link is above)."
          render :action => 'new'
        end
      end

 

2개 이상의 오픈ID 사용하기(오픈ID 목록 관리하기)

한 사람이 2개 이상의 오픈ID를 가지는 경우도 있을 수 있다. 이럴 경우 둘 중 어느 ID로 로그인하든 동일인으로 인식하여 동작하도록 하면 좋을 것이다. 지금부터 그 기능을 구현해 보기로 하자. 기존 계정에 오픈ID를 새로 추가하거나 삭제할 수 있게 하는 것이다. 우선 한 사람이 여러 개의 오픈ID를 가질 수 있는 부분을 구현하자. UserOpenid 모델을 만들면 될 것이다. User와  UserOpenid 간에 has_many 관계를 설정하는 등의 작업은 생략한다.

 

  1.   create_table :user_openids do |t|
        t.column  :openid_url, :string,  :null => false
        t.column  :user_id,    :integer, :null => false
        t.timestamps
      end

 

이어서 routes.rb 파일도 연관관계에 맞도록 변경해 주자.

 

  1.   map.resources :users, :has_many => :openids

 

마지막으로 openid를 처리할 Openids 컨트롤러도 하나 생성하자.  이제 코딩을 시작하자. 우선 간단한 것부터 시작하자. users/edit 페이지에 OpenID를 추가하는 부분을 추가하자. edit 뷰 하단에 다음 코드를 추가한다.

 

  1. <h3>Existing Openids</h3>
    <ul>
        <% current_user.openids.each do |openid| %>
            <li><%= openid.openid_url %></li>
        <% end %>
    </ul>

    <%= link_to 'Add New OpenID', new_user_openid_path(@user) %>

 

이 때 'Add New OpenID' 링크를 클릭했을 경우 보게될 openids/new 페이지는 통상적인 폼이므로 생략하기로 하고, 이 new 폼을 제출했을 경우 호출될 openids 컨트롤러의 create 액션에서는 앞서 sessions 컨트롤러의 create 액션에서 수행한 것과 동일한 인증 로직이 수행되어야 한다. 즉, 사용자가 자기 것이라고 우기는 OpenID가 실제 자기 것인지를 검증하는 절차가 필요하며, 이 때 사용되는 로직은 앞서 sessions 컨트롤러에 있던 open_id_authentication 메서드의 로직과 비슷하다. 비슷한 로직을 따로따로 구현할 수도 있지만 여기서는 기존에 Sessions 컨트롤러 속에 있던 open_id_authentication 코드를 Application 컨트롤러로 이동하고 다음과 같이 약간 수정하는 걸로 하였다. 즉, 아직 로그인하지 않은 사용자가 이 메서드를 호출하는 경우에는 기존처럼 작동하고, 이미 로그인한 사용자가 이 메서드를 호출하는 경우에는 OpenID를 추가하는 요청으로 인식하여 그렇게 작동하게 한 것이다.

 

  1. protected

      def open_id_authentication
        authenticate_with_open_id(nil, :required => [:nickname, :email]) do |result, identity_url, registration|
          if result.successful?
            unless logged_in?
              @user = User.find_or_initialize_by_identity_url(identity_url)
              if @user.new_record?
                @user.login = registration['nickname']
                @user.email = registration['email']
                @user.identity_url = identity_url
                # @user.save(false) # skip validation
                render :template => 'users/new'
              else
                self.current_user = @user
                successful_login
              end 
            else
              @user = current_user
              @openid = @user.openids.build(:openid_url => identity_url)
              if @openid.save
                redirect_to edit_user_path(current_user)
              else
                render :template => 'openids/new'
              end
            end 
          else
            failed_login result.message
          end
        end
      end

 

이걸로 끝일까? 유감스럽게도 아니다. 이직도 해야할 일들이 몇 가지 남아 있다. 우선 다음부터 이 사용자가 새로 추가한 오픈ID로 로그인할 수도 있게끔 해 줘야 한다. 또 이미 추가한 오픈ID를 삭제할 수도 있어야 할 것이다. 우선 다른 오픈ID로 로그인하는 경우부터 처리하도록 하자. 앞서 만든 open_id_authentication 로직을 다음과 같이 조금 변경하여, 오픈ID를 검색하여 User를 추출하는 로직을 추가하자. 코드가 길어지면서 코드에서 안좋은 냄새가 나긴 하지만 리팩터링은 일단 뒤로 미루자.

 

  1.       if result.successful?
            unless logged_in?
              @user_openid = UserOpenid.find_by_openid_url(identity_url)
              if @user_openid
                self.current_user = @user_openid.user
                successful_login
              else
                @user = User.find_or_initialize_by_identity_url(identity_url)
                if @user.new_record?
                  @user.login = registration['nickname']

 

또 한가지 구현할 것은 오픈ID의 중복과 관련된다. OpenID는 unique해야 하기 때문이다. UserOpenid 모델에 다음과 같이 유효성 검증을 추가하면 될 것이다.

 

  1. class UserOpenid < ActiveRecord::Base
      belongs_to :user

      validates_presence_of :openid_url
      validates_uniqueness_of :openid_url
    end

 

끝맺음 - 리팩터링과 DRY!

여기서는 단계별로 구현을 하다보니 User모델에 identity_url 필드가 있고 또 UserOpenid 모델 속에도 openid_url 필드가 존재하는 엉성한 구조가 되었지만, 만약 처음부터 여러 개의 오픈ID를 상정하고 디자인했다면 User모델의 identity_url 필드는 불필요했을 것이며, open_id_authentication 메서드의 코드도 조금 더 간명해 졌을 것이다. 또한 여기 적힌 코드들이 완전한 것은 아니며 군데군데 헛점도 몇 군데 있을 수 있다. 이 글에서 제시하려고 한 것은 완전하고 완벽한 OpenID 인증은 아니며 어디까지나 구현에 참조할 만한 내용을 보이고자 한 것이니,  리팩터링이나 DRY! 및 기능 개선과 관련된 부분의 처리는 독자 여러분께 숙제로 남긴다.

 

이 글은 스프링노트에서 작성되었습니다.


덧글

  • 상욱 2009/03/28 22:38 # 삭제

    마침 지금 restful_authenticaion 플러그인을 이용해서 새롭게 만드는게 있는데.
    처음부터 오픈아이디를 적용되게 해야 겠군요~ 감사합니다 ^^
  • aproxacs 2009/03/29 20:43 # 삭제

    구현하고는 싶은데 복잡해서 그냥 넘어갔던 것들을 고민해 주셨네요. 많은 도움이 됩니다.^^
※ 이 포스트는 더 이상 덧글을 남길 수 없습니다.



Follow me on Twitter

Follow sjoonk on Twitter