16 min read

애저 정적 웹 앱에 배포한 블레이저 웹어셈블리 앱에 Microsoft Graph를 통해 사용자 데이터 출력하기

Justin Yoo

애저 정적 웹 앱은 굉장히 쉽고 간단한 인증 기능을 제공하고 있다. 이를 통하면 별도의 복잡한 인증 절차를 거칠 필요가 없이 쉽게 애저 정적 웹 앱 인스턴스에 로그인할 수 있다. 그런데, 이 인증 관련 정보는 로그인을 했다 아니다 정도만 알 수 있을 뿐, 좀 더 자세한 정보를 알기 위해서는 별도의 작업을 더 해줘야 한다. 이 포스트에서는 애저 정적 웹 앱에 배포한 블레이저 웹어셈블리 앱에서 Microsoft Graph API를 이용해 사용자 프로필 데이터에 접근하는 방법에 대해 알아보기로 한다.

이 포스트에서 사용한 예제 애플리케이션은 이 깃헙 리포지토리를 참고한다.

애저 정적 웹 앱 로그인 사용자 데이터 출력

블레이저 웹어셈블리로 앱을 만들어서 애저 정적 웹 앱으로 배포한 후, 로그인 전의 페이지 상태는 대략 아래와 같은 모양일 것이다.

로그인 이전

블레이저 웹어셈블리 앱을 이용해 애저 정적 웹 앱에 로그인할 때, 애저 액티브 디렉토리를 주 인증 공급자로 활용하려고 한다면 위 그림의 로그인 링크에 아래와 같이 연결하면 된다.

https://<azure_static_webapp>.azurestaticapps.net/.auth/login/aad

로그인 후에는 블레이저 안에서 아래와 같은 코드를 이용해 로그인 데이터에 접근할 수 있다. 가독성을 위해 불필요한 코드는 제거했다.

var baseUri = "https://<azure_static_webapp>.azurestaticapps.net";
var http = new HttpClient() { BaseAddress = new Uri(baseUri) };
var response = await http.GetStringAsync("/.auth/me").ConfigureAwait(false);
view raw 02-auth-me.cs hosted with ❤ by GitHub

이렇게 해서 받은 데이터는 아래와 같이 생겼다.

{
"clientPrincipal": {
"identityProvider":"aad",
"userId":"<guid>",
"userDetails":"<logged_in_email>",
"userRoles":[
"anonymous",
"authenticated"
]
}
}

즉, 애저 정적 웹 앱에서 제공하는 로그인 정보는 위와 같이 제한적인 내용이 전부이다. 따라서, 로그인 사용자의 이름이라든가 다른 정보를 확인하려면 추가적인 작업을 더 해줘야 한다.

Microsoft Graph를 이용한 사용자 데이터 접근

위의 로그인 정보로 알 수 있는 사용자의 개인 정보는 로그인에 사용한 이메일 주소가 전부이다. 여기서 알 수 있는 사실은 아래와 같다:

  • 내 계정이 속한 테넌트로 로그인이 되었다.
  • 로그인 정보는 내가 로그인 한 이메일 주소를 말해준다.
  • 로그인 정보는 내가 로그인 한 테넌트의 정보를 말해주지 않는다.
  • 로그인 정보는 애저 정적 웹 앱 인스턴스가 호스팅되고 있는 테넌트의 정보를 말해 주지 않는다.
  • 로그인 정보는 내가 접근하고자 하는 테넌트의 정보를 말해주지 않는다.

즉, 내가 로그인 한 테넌트, 정적 웹 앱 인스턴스가 호스팅되고 있는 테넌트, 그리고 사용자 데이터가 저장되어 있는 테넌트가 모두 다를 수 있다는 의미이다. 내가 알고 있는 정보는:

  1. 일단 어딘가의 테넌트에 로그인이 되어 있고,
  2. 그 로그인 관련 정보 중에 내가 아는 것은 이메일 주소 뿐이다.

그렇다면, 어떻게 내가 접근하고자 하는 테넌트의 사용자 데이터를 알 수 있을까?

가장 먼저 해야 할 일은 바로 리소스에 접근할 수 있도로 인증하는 일이다. 현재 정적 웹 앱에 로그인은 했지만, 이 로그인 내용만으로는 리소스 접근할 수 없기 때문이다. 애저 정적 웹 앱은 백엔드 API로 애저 펑션을 제공하고 있으므로 애저 펑션 앱을 이용해서 리소스에 접근하도록 하자.

애저 정적 웹 앱의 블레이저 웹어셈블리 쪽에서 API를 호출할 때 항상 로그인 정보를 요청 헤더 x-ms-client-principal를 통해 보낸다. 헤더에 담겨 있는 정보는 Base64 인코딩된 문자열인데 대략 아래와 비슷하게 생겼다.

ewogICJpZGVudGl0eVByb3ZpZGVyIjoiYWFkIiwKICAidXNlcklkIjoiPGd1aWQ+IiwKICAidXNlckRldGFpbHMiOiI8bG9nZ2VkX2luX2VtYWlsPiIsCiAgInVzZXJSb2xlcyI6WwogICAgImFub255bW91cyIsCiAgICAiYXV0aGVudGljYXRlZCIKICBdCn0=

따라서 이렇게 넘어온 데이터를 디코딩하고 비직렬화해서 로그인에 사용한 이메일을 찾아야 한다. 우선 비직렬화에 필요한 개체를 아래와 같이 정의한다.

public class ClientPrincipal
{
[JsonProperty("identityProvider")]
public string IdentityProvider { get; set; }
[JsonProperty("userId")]
public string UserId { get; set; }
[JsonProperty("userDetails")]
public string UserDetails { get; set; }
[JsonProperty("userRoles")]
public IEnumerable<string> UserRoles { get; set; }
}

그리고 애저 펑션의 엔드포인트 안에서 헤더를 아래와 같이 비직렬화 한 후 로그인에 사용한 이메일 주소를 알아낸다.

var bytes = Convert.FromBase64String((string)req.Headers["x-ms-client-principal"]);
var json = Encoding.UTF8.GetString(bytes);
var principal = JsonConvert.DeserializeObject<ClientPrincipal>(json);
var userEmail = principal.UserDetails;

이제 사용자 데이터를 조회하기 위한 기초 작업은 끝났다. 다음 단계로 넘어가 보자.

애저 액티브 디렉토리 접근을 위한 앱 설정

우선 애저 액티브 디렉토리 접근을 위해서는 앱을 하나 등록해야 한다. 애저 포탈을 통해 금방 만들 수 있다. 이 포스트에서는 앱을 등록하는 과정에 대해서는 설명하지 않는 대신 이 문서를 참고하면 좋다. 또한 앱을 등록한 후에는 적절한 권한을 부여해야 한다. 여기서는 위임 권한 대신 애플리케이션 권한을 사용한다. 권한의 범위는 User.Read.All 정도면 충분하다.

위와 같이 앱을 설정한 후에는 고유의 TenantID, ClientID, ClientSecret 값이 부여된다.

Microsoft Authentication Library (MSAL) for .NET

우선 애저 액티브 디렉토리에 저장된 사용자 데이터를 조회하기 위해서는 인증을 해야 한다. 다양한 방법으로 로그인 할 수 있지만, 여기서는 사용자의 로그인 개입 없이 API가 직접 로그인할 수 있도록 하는 클라이언트 자격증명 방법을 사용하기로 한다. 이를 위해서는 아래 NuGet 패키지가 필요하다.

위 패키지를 설치하고 난 후에 local.settings.json 파일에 아래와 같은 환경 변수를 추가한다. 불필요한 부분은 제외하고 인증에 필요한 부분만 남겨뒀다.

{
"Values": {
"LoginUri": "https://login.microsoftonline.com/",
"TenantId": "<tenant_id>",
"ClientId": "<client_id>",
"ClientSecret": "<client_secret>",
"ApiHost": "https://graph.microsoft.com/",
"BaseUrl": "v1.0/"
}
}

이후 아래와 같은 코드를 작성한다. 아래는 별도의 사용자 상호작용 없이 ClientID 값과 ClientSecret 값만으로 액세스 토큰을 받는 방법이다. ConfidentialClientApplicationBuilder 클라스를 이용하면 쉽게 액세스 토큰을 받을 수 있다 (line #16-20).

private async Task<string> GetAccessTokenAsync()
{
var apiHost = Environment.GetEnvironmentVariable("ApiHost");
var scopes = new [] { $"{apiHost.TrimEnd('/')}/.default" };
var options = new ConfidentialClientApplicationOptions()
{
Instance = Environment.GetEnvironmentVariable("LoginUri"),
TenantId = Environment.GetEnvironmentVariable("TenantId"),
ClientId = Environment.GetEnvironmentVariable("ClientId"),
ClientSecret = Environment.GetEnvironmentVariable("ClientSecret"),
};
var authority = $"{options.Instance.TrimEnd('/')}/{options.TenantId}";
var app = ConfidentialClientApplicationBuilder
.Create(options.ClientId)
.WithClientSecret(options.ClientSecret)
.WithAuthority(authority)
.Build();
var result = await app.AcquireTokenForClient(scopes)
.ExecuteAsync()
.ConfigureAwait(false);
var accessToken = result.AccessToken;
return accessToken;
}

이렇게 받아온 액세스 토큰을 이용하면 그 다음부터는 자유롭게 Microsoft Graph API를 사용할 수 있다.

Microsoft Graph API for .NET

Microsoft Graph API 사용을 위해서는 아래 NuGet 패키지가 필요하다.

그리고 아래와 같이 Graph API에 접근하기 위한 코드를 작성한다. 위에 작성했던 액세스 토큰을 받는 메소드인 GetAccessTokenAsync()를 여기서 호출한다 (line #4-8).

private async Task<GraphServiceClient> GetGraphClientAsync()
{
var baseUri = $"{Environment.GetEnvironmentVariable("ApiHost").TrimEnd('/')}/{Environment.GetEnvironmentVariable("BaseUrl")}";
var provider = new DelegateAuthenticationProvider(async p =>
{
var accessToken = await this.GetAccessTokenAsync().ConfigureAwait(false);
p.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
});
var client = new GraphServiceClient(baseUri, provider);
return await Task.FromResult(client).ConfigureAwait(false);
}

마지막으로 애저 펑션 메소드 안에서 위에 작성한 Microsoft Graph API 클라이언트 생성 메소드인 GetGraphClientAsync()를 호출한 후 (line #1), 처음에 만들어 놓은 ClientPrincipal 개체의 이메일 주소를 이용해 사용자 정보를 조회한다 (line #4). 만약 해당 이메일 주소로 조회가 안 될 경우에는 해당 로그인 사용자는 조회하고자 하는 테넌트에 게스트 혹은 외부 사용자로 추가되지 않았기 때문에 404 Not Found 결과를 반환한다 (line #7).

var client = await this.GetGraphClientAsync().ConfigureAwait(false);
var users = await client.Users.Request().GetAsync().ConfigureAwait(false);
var user = users.SingleOrDefault(p => p.Mail == userEmail);
if (user == null)
{
return new NotFoundResult();
}
view raw 10-get-user.cs hosted with ❤ by GitHub

사용자 정보를 받아왔다면 이는 아래와 같이 굉장히 방대한 양을 가지고 있다.

{
"accountEnabled": null,
"ageGroup": null,
"assignedLicenses": null,
...
"displayName": "Justin Yoo",
...
"givenName": "Justin",
...
"mail": "justin.yoo@<external_tenant_name>.onmicrosoft.com",
...
"surname": "Yoo",
"usageLocation": null,
"userPrincipalName": "justin.yoo_<external_tenant_name>.onmicrosoft.com#EXT#@<tenant_name>.onmicrosoft.com",
...
}
view raw 11-user.json hosted with ❤ by GitHub

하지만, 굳이 반환 개체에 이 수많은 정보를 노출시킬 필요는 없으므로 아래와 같이 내가 필요한 정보만 담아두는 개체를 생성한다.

public class LoggedInUser
{
public LoggedInUser(User user)
{
this.Upn = user?.UserPrincipalName;
this.DisplayName = user?.DisplayName;
this.Email = user?.Mail;
}
[JsonProperty("upn")]
public virtual string Upn { get; set; }
[JsonProperty("displayName")]
public virtual string DisplayName { get; set; }
[JsonProperty("email")]
public virtual string Email { get; set; }
}

그리고, 이를 이용해서 반환에 필요한 LoggedInUser 개체로 변환한 후 반환한다.

var loggedInUser = new LoggedInUser(user);
return new OkObjectResult(loggedInUser);

이렇게 애저 펑션으로 사용자 정보를 조회하고 반환하는 API를 작성했다.

애저 정적 웹 앱 사용자 정보 조회

이제 블레이저 웹어셈블리 앱에서 위 API를 호출해서 사용자 정보를 조회한다. 아래는 try { ... } catch { ... } 블록으로 감쌌는데, 만약 API 호출시 에러가 난다면 별다른 조치 없이 조용하게 null 값을 반환하게 하기 위함이다. 실제로는 좀 더 정교하게 에러 처리를 해야겠지만, 여기서는 편의상 이렇게 처리했다.

protected async Task<LoggedInUserDetails> GetLoggedInUserDetailsAsync()
{
var details = default(LoggedInUserDetails);
try
{
using (var response = await this._http.GetAsync("/api/users/get").ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
details = JsonSerializer.Deserialize<LoggedInUserDetails>(json);
}
}
catch
{
}
return details;
}

위의 메소드를 블레이저 컴포넌트에서 호출하는 로직을 아래와 같이 작성해 보자. 편의상 불필요한 코드는 삭제했다 (line #6, 18).

<div class="page">
...
<div class="main">
...
<div class="top-row px-4 text-end">
<span class="px-4">@DisplayName</span> | <a href="/logout">로그아웃</a>
</div>
...
</div>
</div>
@code {
protected string DisplayName;
protected override async Task OnInitializedAsync()
{
var loggedInUser = await GetLoggedInUserDetailsAsync().ConfigureAwait(false);
DisplayName = loggedInUser?.DisplayName ?? "등록된 사용자 아님";
}
}

만약 로그인 한 사용자의 정보가 조회하고자 하는 테넌트에 있다면 아래와 같이 나올 것이다.

로그인 후 화면 - 사용자 정보 있음

만약 로그인 한 사용자의 정보가 조회하고자 하는 테넌트에 없다면 아래와 같이 나올 것이다.

로그인 후 화면 - 사용자 정보 없음

이렇게 해서 사용자 정보를 조회하고 화면에 출력하는 코드를 작성해 봤다.


지금까지 애저 정적 웹 앱 인스턴스에 블레이저 웹어셈블리 앱을 호스팅하고 애저 펑션 API를 통해 MSAL애저 액티브 디렉토리에 인증하고, Microsoft Graph API를 이용해 사용자 정보를 가져오는 방법에 대해 알아보았다. Microsoft Graph API는 Microsoft 365의 거의 모든 리소스에 접근이 가능한 만큼 이를 이용하면 셰어포인트, 팀즈 같은 다른 서비스들도 쉽게 이용할 수 있을 것이다.