728x90
반응형
지난 시간에는 Entity Framework로 글 작성 기능을 구현했지만 로그인과 같은 인증 절차가 없어 누구나 글을 작성할 수 있었다. 이제 지금까지 개발한 웹사이트에 소셜 로그인 기능을 추가해 사용자 인증 체계를 구축해보자.


 

허국현 blog.naver.com/empty_wagon|블로깅을 통한 지식의 공유를 좋아하는 그리스도인 개발자다. NullPointr(www.nullpointr.com)를 스택 오버플로우와 같은 웹사이트로 성장시키기 위해 오늘도 개발에 전념하고 있다.

870여만 명의 가입자 정보가 유출된 ‘KT 개인정보 유출 사건’ 등 잇단 개인정보 유출 사고로 인해 그 어느 때보다 개인정보 보호에 대한 사회적 인식이 높은 시점이다. 여전히 많은 웹사이트는 회원가입을 통해서만 이용할 수 있고, 대부분의 이용자는 웹사이트마다 동일한 아이디와 패스워드를 사용해 해킹으로 인해 노출된 개인정보로 인한 피해가 더 커지는 결과를 초래하기도 한다. 이로 인해 최근 개인정보에 대한 사회적 민감도가 높아지고 소규모 웹사이트에 회원가입을 꺼려하는 경향이 점차 강해지고 있다. 만약 nullpointr.com처럼 소규모 웹사이트를 운영하다면 자체적인 회원가입 기능을 구현하기보단 소셜 로그인 기능과 연동해 가입 과정을 간소화하고 이용자 편의성도 높일 수 있다.

소셜 로그인을 구현해보자
소셜 로그인 기능 구현에 앞서 이와 관련된 프로토콜을 먼저 살펴보자. SNS마다 독자적인 프로토콜을 제공하므로 구글, 페이스북, 트위터, 네이트의 SNS 서비스별 프로토콜을 먼저 살펴보자(<표1> 참조).
트위터와 페이스북처럼 전 세계적으로 잘 알려진 SNS를 비롯해 다양한 SNS가 <표 1>에 정리돼 있지만 네이버와 다음 등은 누락돼 있다. 이 두 기업의 OpenAPI 서비스의 경우 OAuth 1.x를 지원하지만, OAuth로는 로그인한 사용자를 식별할 수 없다. 물론 네이버의 미투데이와 다음의 요즘(Yozm) 등의 SNS를 고려할 수 있지만 미투데이는 자체 인증 프로토콜을 이용하고, 요즘의 경우 OAuth로 인증한 후 Yozm API를 이용한다. 두 서비스 모두 사용자 ID를 얻을 수 있지만 다른 SNS 서비스에 비해 구현 난이도가 높아 이 글에서는 다루지 않는다.

인증 과정 살펴보기
OAuth 1.x와 2.0 그리고 OpenID 등 다양한 프로토콜들의 이용자 인증 과정은 다소 차이가 있지만 기본적인 인증 과정을 <그림 1>처럼 요약할 수 있다.


<그림 1> 간략하게 정리한 소셜 로그인 인증 과정

DotNetOpenAuth 적용
OAuth와 OpenID 프로토콜을 이해한 후 관련 API를 실제로 구현하는 것도 의미가 있지만 DotNetOpenAuth란 오픈소스 라이브러리로 관련 기능을 보다 손쉽게 구현할 수 있다. 
DotNetOpenAuth는 www.dotnetopenauth.net 웹사이트에서 ‘Download the .zip’을 클릭해 다운로드할 수 있다(<그림 2> 참조).


<그림 2> DotNetOpenAuth.net 페이지

이전에 소개했듯 NuGet으로도 DotNetOpenAuth를 다운로드할 수 있지만 DotNetOpenAuth과 관련된 많은 DLL이 프로젝트에 추가돼 References 목록이 다소 복잡해진다. 그러므로 아직 AVC.NET MVC 환경에서의 개발에 익숙하지 않다면 단일 DLL 파일을 다운로드하는 것을 권장한다. 압축을 풀면 <그림 3>처럼 폴더가 개발자를 반겨준다.


<그림 3> DotNetOpenAuth 압축 파일을 푼 모습

각각의 ‘Bin-net…’ 폴더에는 DotNetOpenAuth.dll 파일이 있으며, 이 글에서는 .NET Framework 4.0 환경에서 개발하므로 Bin-net4.0 폴더 안의 DotNetOpenAuth.dll을 사용했다. 지면관계상 다루진 않지만 <그림 3>의 여러 폴더 중 Sample 폴더에는 DotNetOpenAuth와 관련된 주요 사용법에 대한 예제가 있으며, 인터넷 검색을 통해 찾을 수 없는 정보도 많아 DotNet OpenAuth.dll을 활용하는 데 매우 유익하니 참고하자.


<그림 4> References에 DotNetOpenAuth.dll을 추가하기

본론으로 돌아가 Bin-net4.0 폴더의 DotNetOpenAuth.dll을 MasoQna 폴더 내에 복사하고 <그림 4>처럼 References에 추가하면 프로젝트에서 DotNetOpenAuth 라이브러리를 이용하기 위한 모든 준비가 끝났다(<그림 5> 참조).


<그림 5> 프로젝트에 추가된 DotNetOpenAuth

DotNetOpenAuth와 관련된 클래스 구조
이제 DotNetOpenAuth를 이용한 소셜 로그인을 본격적으로 구현해보자. 구현에 앞서 이 라이브러리를 활용해 어떻게 소셜 로그인 기능을 구현해야 할까? 이해를 돕기 위해 이 글에서 구현한 소셜 로그인 클래스 다이어그램을 함께 살펴보자(<그림 6> 참조).


<그림 6> 소셜 로그인 클래스 다이어그램

AuthProvider는 공통된 인증 처리를 담당하고, IAuth Handler와 이 인터페이스의 자식들이 각 프로토콜에서의 인증과 관련된 세부 처리를 담당한다. 트위터와 네이트의 경우 동일한 OAuth 1.x 프로토콜을 이용하기 때문에 AbstractConsumer란 부모 클래스를 공통적으로 이용했다. <그림 6>의 클래스들은 MasoQna 웹사이트에서 로그인을 처리할 Login Controller에서 직간접적으로 이용한다.

LoginController 코드 둘러보기
실제 로그인을 처리하는 LoginController 코드를 살펴보자. 지면 관계상 전체 코드를 다루지 않지만 http://code.google. com/p/masoqna에서 전체 코드를 다운로드할 수 있다. Index와 [HttpPost]Index 함수를 먼저 살펴보자.

<리스트 1> LoginController.Index() 함수

if (Request.Cookies["serviceType"] == null) { // 첫 접근
Session.Remove("providedUserId");
Session.Remove("serviceType");
RemoveCookie();
return View(auth.GetImagePaths());
} else { // 인증 위해 접근
int type = int.Parse(Request.Cookies["serviceType"].Value);
RemoveCookie();

string providedUserId = null;
bool authSuccess = false;

try {
authSuccess = auth.FinishAuthentication(type, out providedUserId);
}
catch (Exception) {
authSuccess = false;
}

if (authSuccess == true) {
if (string.IsNullOrEmpty(providedUserId)) {
RemoveCookie();
return View(auth.GetImagePaths());
}

int userId = userRepo.GetUserId(type, providedUserId);

if (userId != 0) { // 기존 사용자
return ApproveLogin(userId);
} else { // 신규 사용자
Session["providedUserId"] = providedUserId;
Session["serviceType"] = type;
return RedirectToAction("CreateAccount");
}
}
}

로그인 페이지에서는 다양한 인증을 처리하기 때문에 언뜻 보기에도 코드가 상당히 복잡하다(<리스트 1> 참조). 핵심만을 쉽게 설명하기 위해 오류 처리와 관련된 코드를 상당 부분 지웠지만 지금까지 살펴본 코드 중 가장 긴 편에 속한다.
LoginController.Index() 함수는 먼저 이용자가 로그인하기 위한 접근인지 또는 소셜 로그인을 완료한 후 로그인 페이지에 접속한 것인지를 식별하며, 이를 구현하는 데 쿠키를 이용했다(<리스트 2> 참조). 쿠키를 이용한 것은 서버의 특정 공간을 할당해야 할 만큼 보안을 강화할 필요가 없고, 한 이용자가 로그인하고 어떤 웹사이트를 통해 로그인했는가에 대한 정보는 공개돼도 보안상 큰 무리가 없다는 판단에서다.

<리스트 2> [HttpPost]Index()

[HttpPost]
public ActionResult Index(int serviceType, bool rememberMe, string returnUrl = "") {
returnUrl = HttpUtility.UrlDecode(returnUrl);
Session[“returnUrl”] = ValidateReturnUrl(returnUrl) ? returnUrl : "/";

if (auth.IsValidServiceType(serviceType) == false) {
RemoveCookie();
return View("Index_Error_AuthenticationError");
}

try {
Session["rememberMe"] = rememberMe;

Response.Cookies.Add(new HttpCookie("serviceType") {
Value = serviceType.ToString(),
Expires = DateTime.Now.AddMinutes(20),
HttpOnly = true
});

auth.StartAuthentication(serviceType);
} catch (Exception) {
RemoveCookie();
return View("Index_Error_AuthenticationError");
}

return new EmptyResult();
}

returnUrl은 Session으로 관리되고 역할을 다해 불필요할 때에는 Remove로 제거한다. 물론 returnUrl은 20분이 지나면 자동 삭제되지만 보다 확실한 처리를 위해 코드를 추가했다.
StartAuthentication과 FinishAuthentication의 호출에 주목하자. 이들은 각각 [HttpPost]Index() 함수와 Index() 함수에서 호출되며, [HttpPost]Index()의 경우 사용자가 어떤 사이트를 통해 로그인할지를 결정하기 때문에 StartAuthentication를 호출한다. 또한 로그인 후 웹사이트로 정보가 전달될 때 Finish Authentication이 호출되며 여기서 정보는 사이트마다 프로토콜이 다르므로 IAuthProvider를 이용해 처리한다.
사용자 정보 처리의 경우 IUserRepository를 구현하는 User Repository 클래스에서 MD5 해시를 이용해 처리한다. 여기서 MD5 해시는 제공자에게 받은 사용자 ID의 해시값의 길이가 제공자마다 다른 문제를 해결하기 위해 적용됐다.
사용자가 선택한 회사 번호와 제공자가 제공한 ID 해시값을 합쳐 MD5 해시값이 생성되고, 이 값으로 사용자의 가입 여부를 식별한다. 가입하지 않았으면 0이 반환되고 회원가입 페이지가 열린다.

OAuth 구현 방법
이제 페이스북, 트위터, 구글, 네이트 등의 SNS 서비스와 관련된 코드를 실제로 구현해보자. 트위터는 DotNetOpenAuth 예제로 이용될 만큼 비교적 쉽게 소셜 로그인 기능을 구현할 수 있지만 트위터 코드를 네이트에서도 이용할 수 있게 Abstract Consumer로 변환해야 하기 때문에 구현이 다소 어렵다.
트위터는 dev.twitter.com에서 Create an App 버튼을 클릭해 Key와 Secret를 받을 수 있다(<그림 7>과 <그림 8> 참조). 이 과정에서 요구하는 웹사이트 주소는 임의의 값을 입력하면 된다.


<그림 7> 트위터의 Key와 Secret 받기

별도로 배포된 이번 예제의 Web.config에 ‘twitter’로 시작하는 값을 찾고 여기에 발급된 Key와 Secret을 입력한다. 만약 Key와 Secret를 분실했거나 다시 보고 싶은 경우 <그림 9>처럼 발급 페이지의 My Applications을 클릭해 확인할 수 있다.


<그림 8> 트위터의 Key와 Secret 발급 결과

AbstractConsumer 코드 중 TwitterConsumer의 GetUserId 함수를 살펴보자. OAuth 1.x 인증 과정에서 사용자 ID에 대한 해시값이 전달되며, 이 해시값의 명칭은 SNS 서비스마다 달라 GetUserId란 함수로 해시값을 별도로 구했다. 트위터는 user-id란 이름으로 정해져있으며, 네이트의 경우 <그림 10>처럼 http://devsquare.nate.com에서 로그인을 하면 나타나는 오픈 API 탭에서 Consumer Key를 등록할 수 있다.


<그림 9> 발급 정보 재확인하기


<그림 10> 네이트 오픈 API

네이트는 트위터와 달리 1개의 Consumer Key만을 받을 수 있으며, Secret는 네이트 메일로 전달됨에 유념하자. 네이트와 페이스북은 웹사이트 주소 관리를 엄격하게 관리해 주소 문제로 개발 시 실제 서버에서의 테스트가 다소 어렵다. 이를 해결하고 보다 쉽게 개발 과정에서 소셜 로그인 기능을 테스트할 수 있도록 C:\Windows\System32\drivers\etc의 hosts란 파일에 다음과 같은 문장을 하나 추가하자.

127.0.0.1 www.masoqnatest.com

여기서 127.0.0.1은 알다시피 localhost를 의미하므로 로컬에서 www.masoqnatest.com로 접속할 수 있으며, 특정 포트 번호가 필요할 경우 ‘www.masoqnatest.com:포트번호’ 형식으로 이용하면 된다. 네이트의 GetUserId는 다소 구조가 복잡한 만큼 상세히 설명하겠다(<리스트 3> 참조).

<리스트 3> NateConsumer.GetUserId()

public override string GetUserId(IDictionary<string, string> responseExtraData) {
string userId = "";
string serviceType = responseExtraData["SSO"];

if (serviceType.Equals("CO")) {
userId = responseExtraData["CK"];
} else {
userId = responseExtraData["NK"];
}

return serviceType + "_" + userId;
}


수많은 인수 합병으로 인해 네이트의 로그인 체계가 다소 복잡해졌다. 크게 네이트와 싸이월드 아이디로 로그인할 수 있지만 네이트의 경우 nate.com, empas.com, lycos.co.kr, netsgo.com 등의 아이디를 지원해 인증 과정이 상당히 복잡한 편이다. 이런 이유로 사용자의 로그인 방식은 ‘SSO’란 항목에 표시된다. SSO 값으로는 NC, NO, CO가 있으며 각각 네이트와 싸이월드 통합, Nate Only, Cyworld Only를 의미한다. 각 사이트에서 사용자 ID에 대한 정보는 CK(Cyworld Key)와 NK(Nate Key)이며, SSO로 얻은 사용자 로그인 방법과 사용자의 ID로 얻어진 Key를 합친 UserId 문자열이 생성되고 이 문자열이 Abstract Consumer로 전달된다. NK와 CK의 값이 같을 경우 다른 사용자를 동일 사용자로 오인할 수 있어 반환값의 앞에 serviceType을 추가했다.

구글 오픈 아이디
<리스트 4>는 GoogleRelyingParty 코드로, StartAuthenti cation과 FinishAuthentication의 코드를 살펴보자.

<리스트 4> GoogleRelyingParty.cs

public void StartAuthentication() {
var b = new UriBuilder(HttpContext.Current.Request. Url) { Query = "" };

var req = openid.CreateRequest("https://www.google. com/accounts/o8/id", 
ConfigurationManager.AppSettings["googleRealm"], b.Uri);
req.RedirectToProvider();
}

public bool FinishAuthenticaiton(out string providedUserId) {
providedUserId = "";

OpenIdRelyingParty openid = new OpenIdRelyingParty();
var str = openid.GetResponse();

if (str != null && str.Status.Equals(AuthenticationStatus.Authenticated)) {
Uri uri = new Uri(str.ClaimedIdentifier.ToString());
NameValueCollection queries = HttpUtility.ParseQueryString(uri.Query);
providedUserId = queries["id"];
return true;
}
return false;
}


<리스트 4>에서는 ConfigurationManager 클래스로 Web. config에 있는 googleRealm 문자열 값을 얻으며, google Realm에는 운영할 웹사이트의 기본 URL이 들어간다. 예컨대 www. nullpointr.com/Login?elReturnUrl=%252fQuestions%252f57이 현재의 URL일 경우 www.nullpointr.com이 기본 URL이며, 이 값이 틀릴 경우 로그인이 실패되므로 주의하길 바란다.
localhost가 기본 URL일 경우 몇몇의 SNS에서는 로그인이 무조건 실패되지만, 구글 오픈 아이디는 localhost도 인정하는 적은 반면 포트 번호에 따라 동일한 localhost라도 다른 사이트로 인정한다. ASP.NET의 기본 설정에서는 포트 번호가 빈번히 바뀌므로, 이를 고정하기 위해서는 [Project] -> [MasoQna Properties]의 Web 탭에서 Specific port를 사용해야 한다.
인증에 성공하면 FinishAuthentication가 ClaimedIdentifier에 URL을 대입하며, 여기서 id 값이 추출된다(<리스트 4> 참조).

페이스북
페이스북의 Key와 Secret는 http://developers.facebook.com의 상단 앱 메뉴에서 새로운 앱을 추가해 관련 정보를 입력해 얻을 수 있다.
페이스북 개발자 페이지에서 앱을 처음 생성할 때 페이스북은 전화 인증을 요구하므로 문자로 받은 인증 번호를 꼭 입력해야만 한다. 비교적 간단한 과정을 거쳐 페이스북의 Key와 Secret를 얻었을 수 있다. 이제 FacebookHandler를 살펴보자.

<리스트 5> FacebookHandler.cs

public void StartAuthentication() {
IAuthorizationState authorization = client.ProcessUserAuthorization();
if (authorization == null) {
// Kick off authorization request
client.RequestUserAuthorization();
}
}

public bool FinishAuthenticaiton(out string providedUserId) {
IAuthorizationState authorization = client.ProcessUserAuthorization();

var request = 
WebRequest.Create("https://graph.facebook.com/me?access_token=" + 
Uri.EscapeDataString(authorization.AccessToken));
using (var response = request.GetResponse()) {
using (var responseStream = response.GetResponseStream()) {
var graph = FacebookGraph.Deserialize(responseStream);
providedUserId = graph.Id.ToString();
}
}

return true;
}

static FacebookHandler() {
client = new FacebookClient {
ClientIdentifier = ConfigurationManager.AppSettings["facebookAppID"],
ClientCredentialApplicator = ClientCredentialApplicator.PostParameter(ConfigurationManager.AppSettings["facebookAppSecret"]),
};
}

private static FacebookClient client;


ASP.NET MVC에서 컴파일한 후 Debug나 Release 폴더를 살펴봤다면 이미 알겠지만 웹 응용 프로그램의 확장자는 exe가 아닌 dll이며, 웹 응용 프로그램은 웹 서버에서 스레드로 실행된다. static으로 선언될 경우 하나의 웹 응용 프로그램을에서 변수를 한번만 초기화할 수 있을 뿐 아니라 서버가 종료될 때까지 값이 변하지 않는다. 또 FacebookClient의 경우 static을 무시할 경우 인증에 실패할 가능성이 높아 <리스트 5>의 Facebook Client가 static로 선언됐다.
FinishAuthentication의 UserId 생성에 주목하자. 페이스북은 사용자 정보를 그래프로 관리하고 각 사용자마다 고유 ID 숫자가 부여된다. FinishAuthentication 코드는 페이스북의 그래프 API를 이용해 사용자 ID를 얻고, 문자열로 변환한다.

www.’ 없애기
웹 프로그램을 개발하다보면 실제 서버 상에서 실행할 때 문제가 발생하는 경우가 종종 있으며, 소셜 로그인도 그렇다. 구글과 페이스북의 경우 URL을 엄격히 관리하기 때문에 URL에서  ‘www.’의 유무를 구분한다. 이로 인한 로그인 실패를 방지하기 위해서는 URL에 따라 www.을 추가하거나 반대로 www.을 지워 해결할 수 있으며, 이 글에서는 후자의 방법을 택했다.
www.을 없애기 위해서는 Infrastructure 아래에 <리스트 6>을 추가한 후 Global.asax.cs 내부의 RegisterGlobalFilters 함수에 RemoveWwwAttribute를 추가하면 된다.

<리스트 6> RemoveWwwAttribute.cs

public class RemoveWwwAttribute : ActionFilterAttribute {
public override void OnActionExecuting(ActionExecutingContext filterContext) {
HttpRequestBase req = filterContext.HttpContext.Request;
HttpResponseBase res = filterContext.HttpContext.Response;

string host = req.Url.Host.ToLower();
if (host.StartsWith("www.")) {
var builder = new UriBuilder(req.Url) {
Host = host.Substring(4)
};
res.Redirect(builder.Uri.ToString());
}

base.OnActionExecuting(filterContext);
}
}


미상님 제거
소셜 로그인 기능이 구현된 만큼 이제 지난 글에서 로그인 인증 처리를 구현하지 않은 관계로 부득이하게 추가했던 유명 작가인 ‘미상’님을 제거하자(<리스트 7> 참조).

<리스트 7> 미상님 제거

int userId = int.Parse(User.Identity.Name);
User writer = (from u in context.Users
where u.Id == userId
select u).SingleOrDefault();
q.Time = DateTime.Now;
q.Writer = writer;


사용자가 로그인할 때 사용자 번호를 로그인과 관련된 쿠키에 저장했다. 관련된 <리스트 8>의 AuthProvider 코드에서 Forms Authentication.SetAuthCookie란 함수를 찾을 수 있다. 

<리스트 8> AuthProvider 로그인 처리

public void ApproveLogin(string userId, bool rememberMe) {
FormsAuthentication.SetAuthCookie(userId, rememberMe); 
}


이 함수의 userId에 저장된 문자열은 Controller 클래스에서 User.Identity.Name로 접근할 수 있으며, <리스트 7>은 int. Parse를 통해 사용자 ID를 얻는다.


자료 출처 : http://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&wr_id=41036

 

728x90
반응형
블로그 이미지

nineDeveloper

안녕하세요 현직 개발자 입니다 ~ 빠르게 변화하는 세상에 뒤쳐지지 않도록 우리모두 열심히 공부합시다 ~! 개발공부는 넘나 재미있는 것~!

,