Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 2개 있습니다.)

C# - Azure AD 인증을 지원하는 데스크톱 애플리케이션 예제(Windows Forms)

전에 한번 ASP.NET Core/5+ 예제로 다뤄본 적이 있는데요,

C# - Azure AD 인증을 지원하는 ASP.NET Core/5+ 웹 애플리케이션 예제 구성
; https://www.sysnet.pe.kr/2/0/12614

이번엔 데스크톱 프로그램에서 어떻게 Azure AD 인증을 처리할 수 있는지 알아보겠습니다. 그리고, 이에 대한 내용은 다음의 글을 그대로 참고했으니,

Modern authentication with Azure AD for WinForms (native) apps
; https://cmatskas.com/modern-authentication-with-azure-ad-for-winforms-native-apps-2/

그냥 저 링크의 글을 읽으셔도 무방합니다. ^^




일단, 데스크톱 프로그램의 경우에도 "Azure Active Directory"에서 "App Registrations"를 통해 App 유형을 하나 등록해야 하는 것에는 변함이 없습니다. (혹은, 지난 글에 만들어 둔 "test-auth-webapp"을 재사용해도 됩니다.)

그다음, "Authentication" 메뉴로 "Add a platform" 버튼을 이용해 "Mobile and desktop applications" 항목을 추가합니다. 그럼, 다음과 같이 "Redirect URIs"를 묻는 메뉴로 바뀌는데요,

winform_aad_1.png

Web App의 경우에는 Redirect를 web application에서 처리했기 때문에 (로컬 테스트였으므로) "https://localhost:44356/signin-oidc"라는 식으로 지정을 했지만 Desktop 응용 프로그램이라면 사정이 좀 달라집니다. 즉, Redirect 받을 URL이 없는데요 이를 위해 별도의 사이트를 만들어 처리한다면 "Custom redirect URIs"를 지정하는 것도 가능하겠지만 재미있게도 AAD의 경우 인증 용도로 사용할 수 있는 Redirect Endpoint를 제공하기 때문에 그것을 이용해도 좋습니다.

여기서는 가장 상단의 "https://login.microsoftonline.com/common/oauth2/nativeclient" 항목을 선택해 진행합니다. (물론, 나중에도 바꿀 수 있기 때문에 편하게 선택해 줍니다. ^^)

Azure AD 측의 준비는 이게 끝입니다. (지난번에는 "API permissions"도 지정하고 그랬는데 그건 부가적인 작업이었던 듯합니다.)




자, 그럼 이제 Windows Forms 프로젝트를 하나 생성합니다. 그다음, 웹 환경에서는 다음과 같이 2개의 패키지를 추가했었는데,

Install-Package Microsoft.AspNetCore.Authentication.OpenIdConnect
Install-Package Microsoft.Identity.Web

Desktop Application 환경에서는 Microsoft.Identity.Client(MSAL: Microsoft Authentication Library)만 추가하면 됩니다. (물론, 원한다면 순수 REST API를 호출하는 방식도 있습니다.)

Install-Package Microsoft.Identity.Client

또한, ASP.NET Core/5+에서 Startup.cs의 ConfigureServices 메서드 내에서 확장 메서드를 호출했던 것처럼, Desktop 응용 프로그램의 경우에도 "Microsoft.Identity.Client" 패키지 나름의 방식으로 래핑해 주는 메서드 호출을 해야 합니다. 이것을 Program.cs에서 다음과 같이 코딩을 해줍니다.

using AuthDemoWinForms;
using Microsoft.Identity.Client;
using System;
using System.IO;
using System.Linq;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    static class Program
    {
        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        [STAThread]
        static void Main()
        {
            InitializeAuth();

            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new Form1());
        }

        public static string ClientId = "...your_client_id...";
        public static string Tenant = "...your_tenant_id...";

        private static IPublicClientApplication clientApp;
        public static IPublicClientApplication PublicClientApp { get { return clientApp; } }

        // Logging in MSAL.NET
        // ; https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-logging-dotnet

        // Initialize client applications using MSAL.NET
        // ; https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-initializing-client-applications
        private static void InitializeAuth()
        {
            clientApp = PublicClientApplicationBuilder.Create(ClientId)
                    .WithRedirectUri("https://login.microsoftonline.com/common/oauth2/nativeclient")
                    .WithAuthority(AzureCloudInstance.AzurePublic, Tenant)
                    .Build();

            // Token cache serialization in MSAL.NET
            // ; https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization
            // 아래의 방법은 위의 문서에서 "Write your own cache" 방법을 사용
            TokenCacheHelper.EnableSerialization(clientApp.UserTokenCache);
        }
    }
}

남은 것은, 이제 로그인/로그아웃 기능인데요, 우선 로그인부터 구현해 볼까요? ^^

이를 위해 Form1에 로그인 버튼을 추가하고 이벤트 핸들러에 다음과 같이 코드를 추가합니다.

private async void btnLogin_Click(object sender, EventArgs e)
{
    AuthenticationResult result = await Login();
    label1.Text = result.Account.Username;
}

// Get a token from the token cache using MSAL.NET
// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-acquire-token-silently
private async Task<AuthenticationResult> Login()
{
    AuthenticationResult authResult = null;
    var accounts = await Program.PublicClientApp.GetAccountsAsync();
    var firstAccount = accounts.FirstOrDefault();

    string[] scopes = { };

    try
    {
        authResult = await Program.PublicClientApp.AcquireTokenSilent(scopes, firstAccount)
            .ExecuteAsync();
    }
    catch (MsalUiRequiredException ex)
    {
        // A MsalUiRequiredException happened on AcquireTokenSilent.
        // This indicates you need to call AcquireTokenInteractive to acquire a token
        System.Diagnostics.Debug.WriteLine($"MsalUiRequiredException: {ex.Message}");
        try
        {
            authResult = await Program.PublicClientApp.AcquireTokenInteractive(scopes)
                .WithAccount(accounts.FirstOrDefault())
                .WithPrompt(Prompt.SelectAccount)
                .ExecuteAsync();
        }
        catch (MsalException msalex)
        {
            label1.Text = $"Error Acquiring Token:{System.Environment.NewLine}{msalex}";
        }
    }
    catch (Exception ex)
    {
        label1.Text = $"Error Acquiring Token Silently:{System.Environment.NewLine}{ex}";
    }
    return authResult;
}

코드가 좀 낯설어서 그렇지 위의 동작 순서는 간단합니다. 우선 Program.PublicClientApp.GetAccountsAsync(); 메서드 호출 시 TokenCacheHelper.BeforeAccessNotification 메서드가 콜백됩니다.

public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
    lock (FileLock)
    {
        args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
                ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
                                            null, DataProtectionScope.CurrentUser)
                : null);
    }
}

하지만, 최초 호출에는 "CacheFilePath"에 캐시된 token 정보가 없으므로 null을 반환합니다. 그럼, Program.PublicClientApp.GetAccountsAsync 메서드는 0개의 accounts를 반환하게 되고 이로 인해 이후에 호출하는 "Program.PublicClientApp.AcquireTokenSilent" 메서드에서 예외가 발생합니다.

ex  {"No account or login hint was passed to the AcquireTokenSilent call. "}    Microsoft.Identity.Client.MsalUiRequiredException
   at Microsoft.Identity.Client.Internal.Requests.Silent.SilentRequest.<ExecuteAsync>d__5.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Microsoft.Identity.Client.Internal.Requests.Silent.SilentRequest.<ExecuteAsync>d__5.MoveNext()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.ConfiguredTaskAwaitable`1.ConfiguredTaskAwaiter.GetResult()
   at Microsoft.Identity.Client.Internal.Requests.RequestBase.<RunAsync>d__12.MoveNext()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.ConfiguredTaskAwaitable`1.ConfiguredTaskAwaiter.GetResult()
   at Microsoft.Identity.Client.ApiConfig.Executors.ClientApplicationBaseExecutor.<ExecuteAsync>d__2.MoveNext()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   at WindowsFormsApp1.Form1.<Login>d__3.MoveNext()

(메시지에 나오듯이) 당연한 결과이니 무시하시고, 이후 catch 내의 Program.PublicClientApp.AcquireTokenInteractive 메서드가 호출되면 이때 아래 화면과 같이 로그인할 Microsoft Account를 선택하는 창이 뜹니다.

winform_aad_2.png

또한 권한 요청도 확인하고,

winform_aad_3.png

정상적으로 로그인 절차를 밟았으면 TokenCacheHelper.AfterAccessNotification의 실행으로,

public static void AfterAccessNotification(TokenCacheNotificationArgs args)
{
    // if the access operation resulted in a cache update
    if (args.HasStateChanged)
    {
        lock (FileLock)
        {
            // reflect changesgs in the persistent store
            File.WriteAllBytes(CacheFilePath,
                                ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
                                null, DataProtectionScope.CurrentUser));
        }
    }
}

로컬에는 EXE가 위치한 경로에 "WindowsFormsApp1.exe.msalcache.bin3" 파일이 암호화된 토큰 내용을 캐시하게 됩니다. 물론, 이후 다시 로그인을 시도하면 로컬에 캐시된 토큰을 복원해 Program.PublicClientApp.AcquireTokenSilent 호출이 (전에는 실패했지만) 성공하게 됩니다.

참고로, Program.PublicClientApp.AcquireTokenInteractive의 결과로는 다음과 같은 내용을 담고 있습니다.

-       authResult  {Microsoft.Identity.Client.AuthenticationResult}    Microsoft.Identity.Client.AuthenticationResult
        AccessToken "ey...[생략]...1x6g"  string
+       Account {Account username: testusr@test.com environment login.windows.net home account id: AccountId: 0000...[생략]...b66dad} Microsoft.Identity.Client.IAccount {Microsoft.Identity.Client.Account}
+       AuthenticationResultMetadata    {Microsoft.Identity.Client.AuthenticationResultMetadata}    Microsoft.Identity.Client.AuthenticationResultMetadata
+       ClaimsPrincipal {System.Security.Claims.ClaimsPrincipal}    System.Security.Claims.ClaimsPrincipal
+       CorrelationId   {97...[생략]...ce}    System.Guid
+       ExpiresOn   {2021-...[생략]...}  System.DateTimeOffset
+       ExtendedExpiresOn   {2021-...[생략]...}  System.DateTimeOffset
        IdToken "ey...[생략]...LYHQ"  string
        IsExtendedLifeTimeToken false   bool
+       Scopes  Count = 4   System.Collections.Generic.IEnumerable<string> {System.Collections.Generic.HashSet<string>}
        TenantId    "35...[생략]...70"    string
        TokenType   "Bearer"    string
        UniqueId    "2a...[생략]...58"    string

로그 아웃도 마저 구현해야겠지요.

private async void btnLogout_Click(object sender, EventArgs e)
{
    await Logout();
}

// Clear the token cache using MSAL.NET
// ; https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-clear-token-cache
private async Task Logout()
{
    var accounts = await Program.PublicClientApp.GetAccountsAsync();
    if (accounts.Any())
    {
        try
        {
            await Program.PublicClientApp.RemoveAsync(accounts.FirstOrDefault());
            this.label1.Text = "User has signed-out";
        }
        catch (MsalException ex)
        {
            throw new Exception($"Error signing-out user: {ex.Message}");
        }
    }
}

단순히, 로컬에 캐시된 토큰 정보를 Program.PublicClientApp.GetAccountsAsync로 가져와서 있으면 Program.PublicClientApp.RemoveAsync를 호출합니다. 재미있는 것은, 여기서 RemoveAsync는 서버로의 호출을 포함하지 않고 단순히 TokenCacheHelper.AfterAccessNotification 메서드를 콜백해 토큰값이 삭제된 args.TokenCache.SerializeMsalV3() 데이터를 다시 캐시 파일에 쓰는 것으로 완료가 됩니다.

어쨌든, 도대체 내부에 어떤 일이 벌어지고 있는지는 알 수 없으나 ^^ 이렇게 Microsoft.Identity.Client 라이브러리의 도움으로 AAD와의 계정 연동을 쉽게 해결할 수 있습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




한 가지 개인적으로 풀리지 않는 문제가 있는데요, AcquireTokenSilent/AcquireTokenInteractive 호출이 성공했을 때 반환되는 AuthenticationResult의 Scopes 속성에는 다음의 4가지 값을 확인할 수 있습니다.

[0] "openid"    string
[1] "profile"   string
[2] "User.Read" string
[3] "email" string

여기서 문제는, 제가 설정한 "test-auth-webapp"에는 다음과 같이 API Permissions를 5개 추가했는데,

winform_aad_4.png

"SMTP.Send"가 빠져 있는 것입니다. 이후 계속해서 "test-auth-webapp"의 권한 조정을 해도 그 결과가 반영되지 않습니다. 아니... 그렇다면 권한 조정을 위해서는 기존 "test-auth-webapp"을 아예 삭제하고 다른 걸로 새로 만들어 응용 프로그램 측의 "Application (client) ID"를 다시 배포해야 하는 걸까요? (혹시 이에 대해 아시는 분은 덧글 부탁드립니다.)

또는, 권한 확인을 다시 한번 받도록 해야 하는데, Logout 후 다시 Login을 하면 언제나 로그인 창만 뜰 뿐 권한 확인을 묻는 창은 두 번 다시 뜨지 않습니다. 음... 권한 설정을 무효화시켜 다시 로그인을 할 때 권한 설정을 하는 창이 뜨도록 만들어야 하는데... 방법을 못 찾겠군요. ^^;




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/30/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2023-02-21 11시12분
[최원호] 안녕하세요. 궁금한게 있어서 글 남깁니다.

1. 애저 포탈에 앱 등록할때 포탈 계정은 개발사 계정인가요? 사용할 고객사 계정인가요?
2. 개발사 계정이면 디렉토리(테넌트)ID는 고객사 께 되어야 할텐데 그렇지 않나요?
[guest]
2023-02-21 11시29분
일단 위의 글은 개발사와 사용자(고객사)가 같은 경우를 가정하고 있습니다. 일례로 사내에서 사용할 목적의 앱입니다. 말씀하신 것처럼 만약 개발사와 고객사가 분리돼 있다면 그렇게 해야 할 듯합니다. 저도 해당 시나리오로 해본 것은 아니지만, 위의 글 이전에 썼던,

C# - Azure AD 인증을 지원하는 ASP.NET Core/5+ 웹 애플리케이션 예제 구성
; https://www.sysnet.pe.kr/2/0/12614

단계 중 "Supported account types"를 "Accounts in any organization directory ..."로 바꿀 필요가 있을 것 같습니다. (직접 해 본 적이 없어서 확답은 못하겠습니다.)

이참에 ^^ 최원호 님이 해보시고 시나리오를 정리해 주시면 어떨까요?
정성태

1  2  3  4  5  6  7  8  9  10  11  [12]  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13324정성태4/17/20234257.NET Framework: 2108. C# - Octave의 "save -binary ..."로 생성한 바이너리 파일 분석파일 다운로드1
13323정성태4/16/20234151개발 환경 구성: 677. Octave에서 Excel read/write를 위한 io 패키지 설치
13322정성태4/15/20234948VS.NET IDE: 182. Visual Studio - 32비트로만 빌드된 ActiveX와 작업해야 한다면?
13321정성태4/14/20233750개발 환경 구성: 676. WSL/Linux Octave - Python 스크립트 연동
13320정성태4/13/20233747개발 환경 구성: 675. Windows Octave 8.1.0 - Python 스크립트 연동
13319정성태4/12/20234206개발 환경 구성: 674. WSL 2 환경에서 GNU Octave 설치
13318정성태4/11/20234018개발 환경 구성: 673. JetBrains IDE에서 "Squash Commits..." 메뉴가 비활성화된 경우
13317정성태4/11/20234169오류 유형: 855. WSL 2 Ubuntu 20.04 - error: cannot communicate with server: Post http://localhost/v2/snaps/...
13316정성태4/10/20233495오류 유형: 854. docker-compose 시 "json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)" 오류 발생
13315정성태4/10/20233689Windows: 245. Win32 - 시간 만료를 갖는 컨텍스트 메뉴와 윈도우 메시지의 영역별 정의파일 다운로드1
13314정성태4/9/20233768개발 환경 구성: 672. DosBox를 이용한 Turbo C, Windows 3.1 설치
13313정성태4/9/20233860개발 환경 구성: 671. Hyper-V VM에 Turbo C 2.0 설치 [2]
13312정성태4/8/20233844Windows: 244. Win32 - 시간 만료를 갖는 MessageBox 대화창 구현 (개선된 버전)파일 다운로드1
13311정성태4/7/20234347C/C++: 163. Visual Studio 2022 - DirectShow 예제 컴파일(WAV Dest)
13310정성태4/6/20233899C/C++: 162. Visual Studio - /NODEFAULTLIB 옵션 설정 후 수동으로 추가해야 할 library
13309정성태4/5/20234074.NET Framework: 2107. .NET 6+ FileStream의 구조 변화
13308정성태4/4/20233967스크립트: 47. 파이썬의 time.time() 실숫값을 GoLang / C#에서 사용하는 방법
13307정성태4/4/20233749.NET Framework: 2106. C# - .NET Core/5+ 환경의 Windows Forms 응용 프로그램에서 HINSTANCE 구하는 방법
13306정성태4/3/20233576Windows: 243. Win32 - 윈도우(cbWndExtra) 및 윈도우 클래스(cbClsExtra) 저장소 사용 방법
13305정성태4/1/20233938Windows: 242. Win32 - 시간 만료를 갖는 MessageBox 대화창 구현 (쉬운 버전)파일 다운로드1
13304정성태3/31/20234295VS.NET IDE: 181. Visual Studio - C/C++ 프로젝트에 application manifest 적용하는 방법
13303정성태3/30/20233594Windows: 241. 환경 변수 %PATH%에 DLL을 찾는 규칙
13302정성태3/30/20234227Windows: 240. RDP 환경에서 바뀌는 %TEMP% 디렉터리 경로
13301정성태3/29/20234338Windows: 239. C/C++ - Windows 10 Version 1607부터 지원하는 /DEPENDENTLOADFLAG 옵션파일 다운로드1
13300정성태3/28/20233974Windows: 238. Win32 - Modal UI 창에 올바른 Owner(HWND)를 설정해야 하는 이유
13299정성태3/27/20233752Windows: 237. Win32 - 모든 메시지 루프를 탈출하는 WM_QUIT 메시지
1  2  3  4  5  6  7  8  9  10  11  [12]  13  14  15  ...