이 시리즈의 모든 글 보기 : oauth
마침내, OAuth 에 기반한 인증을 적용하는 예제입니다.
이 글을 읽기 전에 이전 글들을 먼저 읽어 보시면, 좀 더 쉬운 이해가 가능합니다.
OAuth 에 기반한 인증 구현은 크게 두 가지 관점에서 바라 볼 수 있습니다.
인증 소비자 vs 인증 제공자
인증 소비자는 OIDC 의 인증 의뢰자(Relying Party)를 가리킵니다.
이를 구현한다는 의미는 외부의 인증 제공자의 인증 서비스를 소비하는 코드를 작성한다는 의미입니다.
인증 제공자는 OIDC 의 공개 ID 제공자(OpenId Provider) 를 가리킵니다.
이를 구현한다는 의미는 외부의 클라이언트에게 인증 서비스를 제공하는 코드를 작성한다는 의미입니다. OIDC는 OAuth 에 기반하기에, 이 구현에는 OAuth 에서 요구하는 모든 기능, 대표적으로 승인 서버, 토큰 서버, 자원 서버를 모두 제공해야 합니다.
딱 봐도 인증 제공자를 구현한다는 것은 매우 어렵습니다.
그래서 보통 외부 패키지의 도움을 받는 것이 효율적인데, 아래는 몇 개를 나열한 것입니다.
- 유상 : AuthO, Duende IdentityServer
- 오픈 소스 : OpenIdDict
위 패키지들은 Asp.Net Core 에 쉽게 통합될 수 있는 구조이고, 인증 소비자를 위한 라이브러리도 제공합니다.
인증 소비자 구현
구글 인증 서비스를 우리 시스템에 적용하고자 합니다.
즉, 우리 시스템의 일부 요소가 승인 클라이언트(OAuth Client) 혹은 인증 의뢰자(OIDC Relying Party) 역할을 하도록 만들 것입니다.
Backend For Frontend (BFF)
아래 링크는 대표적인 공개 클라이언트인 브라우저에서 실행되는 앱
이 처한 보안 위협과 그 보안 위협에 대처하기 위한 방법론을 제시하고 있습니다.
draft-ietf-oauth-browser-based-apps-18
이 글이 쓰인 시점 기준, 최신 버전은 18 번입니다. 버전 18 이전에, 17 개의 방법론이 제안되었는데, 그 제안에 보안 취약성(Vulnerability)이 발견되면, 다시 새로운 제안을 위한 버전업이 반복되어 왔음을 알 수 있을 것입니다.
이 주제에 대해 다른 글을 읽을 때, 과거의 제안에 기초한 것이라면, 참조하지 않는 게 좋습니다.
이 제안을 요약하면, 프론트 엔드 앱에는 보안 토큰을 절대 저장하지 말고 인증 처리는 전부 백엔드에 위임할 수 있도록 BFF 패턴을 도입하라는 것입니다.
BFF 패턴의 간략한 소개는 MS 문서로 갈음합니다.
Backends for Frontends pattern - Azure Architecture Center | Microsoft Learn
Public UI & Confidential RP
현재 많은 강좌 혹은 MS의 문서에서, BFF 패턴을 바탕으로 외부 인증 예제를 보여 주고 있습니다.
그런데, 대부분 프론트 엔드 앱과 백엔드가 같은 도메인이라는 점을 전제하고 있습니다. 이는 프론트 엔드 앱이 닷넷 웹서버에서 브라우저로 다운로드됨을 의미합니다.
여기에 해당되는 백엔드 프로젝트는 아래와 같습니다.
- Asp.Net Core MVC
- Asp.Net Core Web App(Pages)
- Asp.Net Core Blazor Web App (Webassembly interactive 포함)
그러나 예제는 프론트 엔드 앱이 별도의 서버에서 다운로드되는 상황을 전제합니다.
즉, 프론트엔드는 Blazor WebAssemly Standalone 으로 작성하고, CDN을 통해 배포되는 시나리오를 가정하고, 백엔드(RP)는 AspNet Core Web Api 로 작성할 것입니다.
- Asp.Net Core Web Api : 우리 시스템의 데이터 백엔드, RP
- Blazor Webassembly Standalone App : 우리 시스템의 웹 UI
- OP : Google OAuth API (예제를 위해 구글 인증 채택)
- Static Webserver(CDN) : 프론트 엔드 앱 오리진. Git Hub Pages, Cloudflare Pages, …
사전 준비 - OP 설정, RP 계정 생성
인증 소비자를 구현할 때 제일 먼저 해야 할 일은 인증 제공자 OP를 설정하고, 그 OP의 인증 서비스에 접근할 클라이언트( RP)의 계정을 생성하는 것입니다.
이렇게 생성된 계정 정보는 우리가 구현할 RP가 사용할 것입니다.
구글의 경우, 구글 클라우드 자원 관리자 콘솔에서 수행할 수 있는데, 이 관리자를 찾아 가는 게 조금 번잡합니다. 간편함을 위해, 구글에 로그인한 후, 아래 링크를 사용하는 것을 추천합니다.
자원 관리 화면에서 아래 순서를 따라 프로젝트를 생성합니다.
1. 구글 클라우드 프로젝트 생성
자원 관리 콘솔에서 [+프로젝트 만들기]를 누르고,
그 다음 화면에서 아래와 같이 입력합니다.
- 프로젝트 이름 : 구글 자원을 관리하는 단위입니다. 맘에 드는 이름으로 설정하세요.
- 위치 : "조직(Organization)"을 가리킵니다. "조직 없음"으로 두어도 됩니다.
[만들기] 버튼을 누르면 새 프로젝트가 생성됩니다.
약간의 시간(몇 초에서 몇 분)이 지나면 화면은 자원 관리자 화면으로 돌아 오는데, 이 때 화면 우측 상단의 알림 아이콘을 클릭하면 생성된 프로젝트를 확인할 수 있습니다.
2. 구글 프로젝트에 OP 설정
구글 클라우드는 "OAuth API"라는 서비스로 OP의 기능을 제공합니다.
앞서 생성된 프로젝트에 이 기능을 추가하기 위해서는 [프로젝트 선택] 을 눌러, 생성된 프로젝트 홈 대시 보드로 이동합니다.
대시 보드 화면 가운데에, API 카드가 있는데 그 카드 아래에 [API 개요]를 눌러 [API & 서비스] 관리 화면으로 이동합니다.
[API & 서비스] 관리 화면 화면에서, 앞서 생성된 프로젝트에, OAuth API를 추가할 수 있는데, 이 API를 추가할 때 관련 설정을 전부 해줘야 합니다. 이 설정은 아래의 순서로 진행됩니다.
동의 화면 구성 → 승인 범위 설정 → 테스트 사용자 등록
제일 먼저 동의 화면 구성을 시작하려면, API 대시 보드 좌측 메뉴 중 [OAuth 동의 화면] 을 클릭합니다.
동의 화면 구성
- 사용자 유형 선택
외부 사용자를 선택하고, 만들기를 누릅니다.
(처음이라면, 화면 마다 추가 안내가 있으니 읽어 보시길)
- 사용자에게 표시될 항목과 연락처 입력
그 다음 단계로 사용자 승인 화면에 표시될 내용을 입력하는 화면이 나옵니다.
*로 표시된 필수 항목을 입력합니다.
아래에 있는 [저장하고 계속] 을 누르면, 동의 화면 구성 단계를 마치고, 승인 범위 설정 단계로 넘어 갑니다.
참고로 승인 범위는 승인 요청의 scope 파라미터를 가리킵니다.
이에 대한 자세한 사항은 이전 글을 참고하세요.
승인 범위 설정
[범위 추가 또는 삭제] 를 클릭하면,
승인 범위를 선택하는 팝업 창이 뜹니다.
아래와 같이 선택하고, 팝업 창 아래에 [업데이트] 를 눌러 선택을 저장합니다.
선택 창이 닫히면, 선택된 내용들이 [민감하지 않은 범위] 항목으로 나열됩니다.
범위의 명칭은 URL 형태일 수도 있고, 키워드 형태일 수도 있습니다.
이 설정으로 사용자는, 우리 RP가 사용자 이메일, 사용자 이름, 공개 ID(Open Id) 에 접근하는 것에 동의한다는 화면을 보게 됩니다.
화면 아래에 [동의 후 계속]을 눌러 다음 단계로 이동합니다.
테스트 사용자 등록
구글은 최근 클라이언트 앱의 수명 주기를 테스트 단계와 배포 단계로 구분해 놓았고, 테스트 단계에서는 테스터로 등록된 (구글) 사용자의 동의만 구할 수 있도록 해 놨습니다.
나중에 클라이언트 앱을 운영 단계로 설정하면, 이러한 제한은 없어집니다.
간편성을 위해 본인의 구글의 이메일을 등록합니다.
[Add Users]를 눌러 본인의 이메일을 저장하고
[저장 후 계속] 버튼을 누르면 요약 화면을 보여 주는 것으로 OAuth API 가 추가됩니다.
요약 화면에서는 지금까지 설정한 모든 값을 전부 보여 줍니다.
3. OAuth 클라이언트(RP) 등록
이전 단계에서 추가된 구글 OAuth API의 클라이언트를 등록할 차례입니다.
API 대시 보드 메뉴에서 [사용자 인증 정보]를 클릭합니다.
화면의 메뉴 이름은 원래 Client Credentials 인데, 잘 못된 번역으로 인해 혼란을 부추기는 것 같습니다. 이 단계에 나타나는 "사용자"라는 단어는 모두 Client(RP) 를 가리킵니다.
그 다음 [+ 사용자 인증 정보 만들기] 를 누르고, 선택 팝업 메뉴에서 [OAuth 클라이언트 ID] 를 선택한 후,
[웹어플리케이션]을 선택합니다.
[웹어플리케이션]은 인증 클라이언트가 웹 서버임을 나타냅니다.
우리 시스템은 RP를 웹 서버로 구현하기로 했기 때문에 이 값을 선택한 것입니다.
그 아래에 다른 항목들이 있는데, 대부분 네이티브 앱을 위한 것입니다.
만약 다른 항목을 선택한다면, 이 글의 내용과 맞지 않게 됩니다.
선택을 완료하는 즉시, 그 아래에 승인 요청과 토큰 요청에 포함되어야 Redirect Uri 파라미터 값을 설정하는 항목이 나옵니다.
승인된 리디렉션 URI
이 URI는 사용자가 부라우저에서 동의 후, 승인 서버가 리다이렉트할 주소로, RP의 엔드 포인트 중 하나가 되어야 합니다. (이 주소에 승인 코드나 ID 토큰이 포함됩니다.)
[URI 추가] 를 눌러 아래와 같이 입력하고 [만들기]를 누릅니다.
개발 환경이기 때문에 위 localhost를 입력했지만, 운영 단계에서는 RP의 도메인이나 공개 IP를 입력해야 합니다.
[만들기] 를 누르면 아래와 같이 클라이언트 계정 정보가 나타납니다.
화면 아래에 [ JSON 다운로드] 버튼을 누르면, 이러한 정보를 JSON 파일로 받을 수 있습니다. 이 파일을 받아 개발 PC에 저장하고 파일을 열어 봅니다.
파일에는 클라이언트 ID와 비밀번호도 나와 있지만, 인증 흐름을 시작하는 주소(auth_uri), 토큰을 받을 수 있는 주소(token_uri) 도 나와 있습니다. 뿐만 아니라 혹시나 https 를 위한 인증서가 안 깔려 있을까봐, 인증서를 다운로드 받을 수 있는 주소도 있습니다.
여기까지, 승인 서버의 준비가 끝났습니다.
Confidential RP 구현
우리 시스템의 UI 앱(블레이저 와즘 앱)이 사용할 RP를 기밀 클라이언트로 구현합니다.
비어 있는 Asp.Net Core Web Api 프로젝트를 생성합니다.
RP 기능을 위한 설정
그 다음, appsettings.json 파일에 앞서 구글로부터 받은 OAuth 관련 정보를 선별하여 추가합니다.
"Authenticates": {
"Google": {
"ClientId": "...",
"ClientSecret": "...",
"AuthUri": "https://accounts.google.com/o/oauth2/auth",
"TokenUri": "https://oauth2.googleapis.com/token",
"RedirectUri": "https://localhost:5004/on-consent/google"
}
}
그 다음, /properties/launchSettings.json 에서 applicationUrl 항목의 https 주소를 RedirectUri 의 그것과 일치 시킵니다.
...
"https": {
.... ,
// "applicationUrl": "https://localhost:{임의의 포트 번호};http://localhost:5266",
"applicationUrl": "https://localhost:5004;http://localhost:5266",
...
},
...
앱을 실행하면, 브라우저가 열리고 https://localhost:5004/weatherforecast 로 이동하는 지 확인합니다. (안된다면, lauchSettings.json 을 확인합니다.)
승인 요청 엔드 포인트 추가
RP 의 승인 요청 엔드 포인트는 OP(구글)의 승인 엔드 포인트로 Redirect 하는 역할을 합니다.
이를 구현하기 위해, Program.cs 에 아래와 같이 미니멀 엔드 포인트를 추가합니다.
var grant = app.MapGroup("/grant");
grant.MapGet("google", (IConfiguration config, HttpContext httpContext) =>
{
var google = config.GetSection("Authenticates:Google");
var opAuthUri = google.GetSection("AuthUri").Value;
var scope = "openid email profile";
var responseType = "code";
var redirectUri = google.GetSection("RedirectUri").Value;
var location = $"{opAuthUri }?response_type={responseType}&scope={scope}&redirect_uri={redirectUri}";
httpContext.Response.Redirect(location);
});
이전 글에서 설명한 대로, response_type, scope, return_uri 파라미터를 설정했습니다.
RP를 실행하고, /grant/google 로 접속하면, 구글의 사용자 동의 화면이 나올 것입니다.
그런데, 결과는 아래와 같습니다.
구글은 Client Id 가 필수 파라미터인 것 같습니다.
이를 위해 파라미터를 추가합니다.
grant.MapGet("/google", (IConfiguration config, HttpContext httpContext) =>
{
// ...
var clientId = google.GetSection("ClientId").Value;
var location = $"{opAuthUri}?response_type={responseType}&scope={scope}" +
$"&redirect_uri={redirectUri}&client_id={clientId}";
httpContext.Response.Redirect(location);
});
이번에는 제대로된 응답을 받았습니다.
여기에 사전에 등록된 테스터 이메일을 입력하고, 동의를 클릭합니다.
참고
만약 동의 화면에서 문제가 생긴다면, 새로운 브라우저 창을 열고, 거기에서 RP 의 승인 요청 주소를 입력합니다.
토큰 요청 엔드 포인트 추가
사용자가 접근에 동의하면, OP 는 승인 코드를 발급하고, 사전에 등록된 Uri에 승인 코드를 파라미터로 추가한 후, Redirect 시킵니다.
"Authenticates": {
"Google": {
...
"RedirectUri": "https://localhost:5004/on-consent/google"
}
}
이 Redirect 를 처리하기 위한 엔드 포인트를 추가합니다.
이 엔드 포인트는 OP의 토큰 엔드 포인트에 승인 코드를 전달해서, ID 토큰과 접근 토큰으로 교환해야 합니다.
Program.cs 에 아래의 코드를 추가합니다.
var onConsent = app.MapGroup("/on-consent");
onConsent.MapGet("/google", async (
IConfiguration config,
[FromQuery(Name = "code")] string authzCode) =>
{
var google = config.GetSection("Authenticates:Google");
var opTokenUri = google.GetSection("TokenUri").Value;
var grantType = "authorization_code";
var redirectUri = google.GetSection("RedirectUri").Value;
var clientId = google.GetSection("ClientId").Value;
var clientSecret = google.GetSection("ClientSecret").Value;
var location = $"{opTokenUri}?&grant_type={grantType}&code={authzCode}" +
$"&redirect_uri={redirectUri}&client_id={clientId}&client_secret={clientSecret}";
var body = await (await new HttpClient().PostAsync(location, null))
.Content.ReadFromJsonAsync<dynamic>();
// ID 토큰의 정보 확인
// 자체 인증 티켓(신분증) 발급(쿠키 발급)
return body;
});
보시다 시피, 미완성 구현인데, 이는 구글 OAuth 서비스가 ID 토큰에 어떤 정보를 담았는 지 보기 위함입니다.
앱을 다시 실행시키고, `grant/google/’ 로 이동한 후, 승인을 허락하면(생략될 수 있음), 아래와 같은 바디를 응답 받을 것입니다.
{
"access_token": " ... ",
"expires_in": 3599,
"scope": "https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email",
"token_type": "Bearer",
"id_token": "xxx.yyy.zzz"
}
현재 우리의 관심사는 id_token 입니다.
구글 OAuth API 에서 접근 토큰(access_token)은 Id 토큰의 리프레시 토큰입니다. 즉, 새로운 ID 토큰을 발급 받으려면, 동의 절차를 새로 시작하는 것이 아니라, OP의 토큰 엔드 포인트에 아래의 파라미터를 추가합니다.
?client_id={ … }&client_secret={ … }&refresh_token={ 접근 토큰 } &grant_type=refresh_token
접근 토큰의 수명이 만료되면, { 접근 토큰 } 자리에
refresh_token
값을 넣어, 새로운 ID 토큰과 접근 토큰을 받습니다.
우리는 승인 요청 시에 아래와 같이 승인 범위를 요청했습니다.
var scope = "openid email profile";
이는 ID 토큰에 이메일과 profile 정보가 포함되기를 요청한 것입니다.
JWT 형태인 ID 토큰의 페이로드를 확인해 보면,
"azp": " ... ",
"aud": " nnn-xxx ", // 클라이언트 ID
"sub": "nnn", // Open Id
"email": "xxx@yyy.zzz",
"email_verified": true,
"at_hash": " ... ",
"name": " { 홍길동 } ",
"picture": "https:// ... ",
"given_name": " { 홍길동 }",
"iat": nnn,
"exp": nnn
}
우리가 요청한 사용자 정보가 모두 포함되어 있음을 알 수 있습니다.
scope=“openid” 만 지정한 경우, ID 토큰의 페이로드에 email 과 profile 정보는 포함되지 않습니다.
사용자 정보는 곧 인증의 결과물이라, RP가 OP(구글)에 의뢰한 사용자 인증 절차가 성공적으로 완료되었다는 의미가 됩니다.
사용자 편의 개선
그런데, 외부 인증이 성공적이었다고 이를 그대로 적용한다면, 사용자는 매번 승인 허락을 해야 하는 불편이 있습니다. 뿐만 아니라, 인증을 위해 추가적인 통신 세션(Get → Redirect → Get → Reponse, Get → Redirect → Get → Post → Response)이 결부 되는 비효율도 발생합니다.
이러한 불편을 줄이기 위해서는 외부 인증이 최초로 성공한 경우, 우리는 사용자가 일정 시간 사용할 수 있는 신분증을 발급, 다시 말하면, 자체 로그인을 수행할 수 있습니다.
이 신분증 매체로 토큰과 쿠키 중 하나를 선택하는 것이 보통입니다.
그런데, 앞서 업급한 제안의 BFF 패턴은 브라우저에서 실행되는 앱이 보안 토큰을 저장하면 안된다는 원칙을 전제하므로, (httponly) 쿠키로 발급해야 합니다.
자체 로그인과 API의 UI(블레이저 와즘)의 구현은 다음 글에 이어집니다.