ASP.NET Core에서 외부 공급자의 추가 클레임 및 토큰 유지

ASP.NET Core 앱은 Facebook, Google, Microsoft 및 Twitter와 같은 외부 인증 공급자로부터 추가 클레임 및 토큰을 설정할 수 있습니다. 각 공급자는 해당 플랫폼에서 사용자에 대한 다양한 정보를 제공하지만 사용자 데이터를 수신하고 추가 클레임으로 변환하는 패턴은 동일합니다.

필수 조건

앱에서 지원할 외부 인증 공급자를 결정합니다. 각 공급자에 대해 앱을 등록하고 클라이언트 ID 및 클라이언트 암호를 얻습니다. 자세한 내용은 ASP.NET Core Facebook 및 Google 인증을 참조하세요. 샘플 앱은 Google 인증 공급자를 사용합니다.

클라이언트 ID 및 클라이언트 암호 설정

OAuth 인증 공급자는 클라이언트 ID 및 클라이언트 암호를 사용하여 앱과 트러스트 관계를 설정합니다. 클라이언트 ID 및 클라이언트 암호 값은 앱이 공급자에 등록될 때 외부 인증 공급자에 의해 앱에 대해 생성됩니다. 앱에서 사용하는 각 외부 공급자는 공급자의 클라이언트 ID 및 클라이언트 암호와 독립적으로 구성되어야 합니다. 자세한 내용은 해당하는 외부 인증 공급자 항목을 참조하세요.

인증 공급자의 ID 또는 액세스 토큰으로 전송되는 선택적 클레임은 일반적으로 공급자의 온라인 포털에서 구성됩니다. 예를 들어 Microsoft Azure Active Directory(AAD)를 사용하면 앱 등록의 토큰 구성 블레이드에서 앱의 ID 토큰에 선택적 클레임을 할당할 수 있습니다. 자세한 내용은 방법: 앱에 선택적 클레임 제공(Azure 설명서)을 참조하세요. 다른 공급자의 경우 외부 설명서 집합을 참조하세요.

샘플 앱은 Google에서 제공하는 클라이언트 ID 및 클라이언트 암호를 사용하여 Google 인증 공급자를 구성합니다.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebGoogOauth.Data;

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => 
                                  options.SignIn.RequireConfirmedAccount = true)
                                 .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

// Remaining code removed for brevity.

인증 범위 설정

Scope를 지정하여 공급자에서 검색할 권한 목록을 지정합니다. 일반 외부 공급자에 대한 인증 범위는 다음 표에 나와 있습니다.

Provider Scope
Facebook https://www.facebook.com/dialog/oauth
Google profile, email, openid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

샘플 앱에서 Google의 profile, emailopenid 범위는 AuthenticationBuilder에서 AddGoogle이 호출되면 프레임워크에 의해 자동으로 추가됩니다. 앱에 추가 범위가 필요한 경우 옵션에 추가합니다. 다음 예제에서는 사용자의 생일을 검색하기 위해 Google https://www.googleapis.com/auth/user.birthday.read 범위를 추가합니다.

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

사용자 데이터 키 매핑 및 클레임 만들기

공급자의 옵션에서 앱 ID가 로그인 시 읽을 JS외부 공급자의 JSON 사용자 데이터에 있는 각 키/하위 키에 대해 MapJsonKey 또는 MapJsonSubKey를 지정합니다. 클레임 형식에 대한 자세한 내용은 ClaimTypes를 참조하세요.

샘플 앱은 Google 사용자 데이터의 urn:google:localeurn:google:picture 키에서 로케일(locale) 및 사진(picture) 클레임을 만듭니다.

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync에서 IdentityUser(ApplicationUser)는 SignInAsync를 통해 앱에 로그인됩니다. 로그인 프로세스 중에 UserManager<TUser>Principal에서 사용할 수 있는 사용자 데이터에 대한 ApplicationUser 클레임을 저장할 수 있습니다.

샘플 앱 OnPostConfirmationAsync 에서 (Account/ExternalLogin.cshtml.cs)는 다음에 대한 클레임을 포함하여 로그인ApplicationUser에 대한 로캘(urn:google:locale) 및 그림(urn:google:picture) 클레임을 GivenName설정합니다.

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

기본적으로 사용자의 클레임은 인증 cookie에 저장됩니다. 인증 cookie가 너무 크면 다음과 같은 이유로 앱이 실패할 수 있습니다.

  • 브라우저에서 cookie 헤더가 너무 긴 것을 감지합니다.
  • 요청의 전체 크기가 너무 큽니다.

사용자 요청을 처리하는 데 많은 양의 사용자 데이터가 필요한 경우:

  • 요청 처리에 대한 사용자 클레임의 수와 크기를 앱에 필요한 것으로 제한합니다.
  • Cookie 인증 미들웨어의 SessionStore에 대한 사용자 지정 ITicketStore를 사용하여 요청 간에 ID를 저장합니다. 작은 세션 식별자 키만 클라이언트에 보내는 동안 서버에서 대량의 ID 정보를 유지합니다.

액세스 토큰 저장

SaveTokens는 성공적인 권한 부여 후에 액세스 및 새로 고침 토큰을 AuthenticationProperties에 저장해야 하는지 여부를 정의합니다. SaveTokens는 최종 인증 cookie의 크기를 줄이기 위해 기본적으로 false로 설정됩니다.

샘플 앱은 GoogleOptions에서 SaveTokens 값을 true로 설정합니다.

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync을 실행하는 경우 ApplicationUserAuthenticationProperties 외부 공급자에 액세스 토큰 (ExternalLoginInfo.AuthenticationTokens)을 저장 합니다.

샘플 앱은 액세스 토큰 OnPostConfirmationAsync 을 (새 사용자 등록) 및 OnGetCallbackAsync (이전에 등록된 사용자)에 저장합니다.Account/ExternalLogin.cshtml.cs

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

참고 항목

서버 쪽 앱의 구성 요소에 토큰을 Razor 전달하는 방법에 대한 자세한 내용은 서버 쪽 Blazor ASP.NET Core Blazor 추가 보안 시나리오를 참조하세요.

추가 사용자 지정 토큰을 추가하는 방법

SaveTokens의 일부로 저장되는 사용자 지정 토큰을 추가하는 방법을 보여주려면 샘플 앱은 TicketCreatedAuthenticationToken.Name에 대해 현재 DateTime에서 AuthenticationToken를 추가합니다.

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

클레임 만들기 및 추가

프레임워크는 컬렉션에 클레임을 만들고 추가하기 위한 일반적인 작업 및 확장 메서드를 제공합니다. 자세한 내용은 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions를 참조하세요.

사용자는 ClaimAction에서 파생되고 추상 Run 메서드를 구현하여 사용자 지정 작업을 정의할 수 있습니다.

자세한 내용은 Microsoft.AspNetCore.Authentication.OAuth.Claims를 참조하세요.

사용자 클레임 추가 및 업데이트

클레임은 로그인할 때가 아니라 처음 등록할 때 외부 공급자에서 사용자 데이터베이스로 복사됩니다. 사용자가 앱을 사용하도록 등록한 후 앱에서 추가 클레임을 사용하도록 설정한 경우 사용자에 대해 SignInManager.RefreshSignInAsync를 호출하여 새 인증 cookie를 강제로 생성합니다.

테스트 사용자 계정으로 작업하는 개발 환경에서는 사용자 계정을 삭제하고 다시 만들면 됩니다. 프로덕션 시스템의 경우 앱에 추가된 새 클레임을 사용자 계정에 백필할 수 있습니다. Areas/Pages/Identity/Account/Manage의 앱에 ExternalLogin 페이지를 스캐폴딩한 후 ExternalLogin.cshtml.cs 파일의 ExternalLoginModel에 다음 코드를 추가합니다.

추가된 클레임의 사전을 추가합니다. 사전 키를 사용하여 클레임 형식을 보관하고 값을 사용하여 기본값을 유지합니다. 다음 줄을 클래스 맨 위에 추가합니다. 다음 예제에서는 일반 헤드샷 이미지를 기본값으로 사용하여 사용자의 Google 사진에 대해 하나의 클레임이 추가되었다고 가정합니다.

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

OnGetCallbackAsync 메서드의 기본 코드를 다음 코드로 바꿉니다. 코드는 클레임 사전을 반복합니다. 클레임은 각 사용자에 대해 추가(백필)되거나 업데이트됩니다. 클레임이 추가되거나 업데이트되면 기존 인증 속성(AuthenticationProperties)을 유지하면서 SignInManager<TUser>를 사용하여 사용자 로그인을 새로 고칩니다.

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider,
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user,
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key,
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }
    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;
        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }
        return Page();
    }
}

사용자가 로그인하는 동안 클레임이 변경되지만 백필 단계가 필요하지 않은 경우에도 비슷한 접근 방식이 수행됩니다. 사용자의 클레임을 업데이트하려면 사용자에 대해 다음을 호출합니다.

클레임 작업 및 클레임 제거

ClaimActionCollection.Remove(String)는 컬렉션에서 지정된 ClaimType에 대한 모든 클레임 동작을 제거합니다. ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String)은 ID에서 지정된 ClaimType의 클레임을 삭제합니다. DeleteClaim은 주로 OIDC(OpenID Connect)와 함께 사용하여 프로토콜 생성 클레임을 제거합니다.

샘플 앱 출력

샘플 앱을 실행한 다음 MyClaims 링크를 선택합니다.

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

프록시 또는 부하 분산 장치를 사용하여 요청 정보 전달

앱이 프록시 서버 또는 부하 분산 장치 뒤에 배포되는 경우 원래 요청 정보의 일부가 요청 헤더로 앱에 전달될 수 있습니다. 이 정보에는 일반적으로 보안 요청 체계(https), 호스트 및 클라이언트 IP 주소가 포함됩니다. 앱은 원래 요청 정보를 검색 및 사용하기 위해 이 요청 헤더를 자동으로 읽지 않습니다.

이 체계는 외부 공급자의 인증 흐름에 영향을 주는 링크 생성에 사용됩니다. 보안 체계(https)가 손실되면 앱이 잘못되고 안전하지 않은 리디렉션 URL을 생성합니다.

전달된 헤더 미들웨어를 사용하여 요청 처리를 위한 원본 요청 정보를 앱에 제공합니다.

자세한 내용은 프록시 서버 및 부하 분산 장치를 사용하도록 ASP.NET Core 구성을 참조하세요.

샘플 코드 보기 및 다운로드(다운로드 방법)

ASP.NET Core 앱은 Facebook, Google, Microsoft 및 Twitter와 같은 외부 인증 공급자로부터 추가 클레임 및 토큰을 설정할 수 있습니다. 각 공급자는 해당 플랫폼에서 사용자에 대한 다양한 정보를 제공하지만 사용자 데이터를 수신하고 추가 클레임으로 변환하는 패턴은 동일합니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

필수 조건

앱에서 지원할 외부 인증 공급자를 결정합니다. 각 공급자에 대해 앱을 등록하고 클라이언트 ID 및 클라이언트 암호를 얻습니다. 자세한 내용은 ASP.NET Core Facebook 및 Google 인증을 참조하세요. 샘플 앱은 Google 인증 공급자를 사용합니다.

클라이언트 ID 및 클라이언트 암호 설정

OAuth 인증 공급자는 클라이언트 ID 및 클라이언트 암호를 사용하여 앱과 트러스트 관계를 설정합니다. 클라이언트 ID 및 클라이언트 암호 값은 앱이 공급자에 등록될 때 외부 인증 공급자에 의해 앱에 대해 생성됩니다. 앱에서 사용하는 각 외부 공급자는 공급자의 클라이언트 ID 및 클라이언트 암호와 독립적으로 구성되어야 합니다. 자세한 내용은 시나리오에 적용되는 외부 인증 공급자 항목을 참조하세요.

인증 공급자의 ID 또는 액세스 토큰으로 전송되는 선택적 클레임은 일반적으로 공급자의 온라인 포털에서 구성됩니다. 예를 들어 Microsoft AAD(Azure Active Directory)를 사용하면 앱 등록의 토큰 구성 블레이드에서 앱의 ID 토큰에 선택적 클레임을 할당할 수 있습니다. 자세한 내용은 방법: 앱에 선택적 클레임 제공(Azure 설명서)을 참조하세요. 다른 공급자의 경우 외부 설명서 집합을 참조하세요.

샘플 앱은 Google에서 제공하는 클라이언트 ID 및 클라이언트 암호를 사용하여 Google 인증 공급자를 구성합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

인증 범위 설정

Scope를 지정하여 공급자에서 검색할 권한 목록을 지정합니다. 일반 외부 공급자에 대한 인증 범위는 다음 표에 나와 있습니다.

Provider Scope
Facebook https://www.facebook.com/dialog/oauth
Google profile, email, openid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

샘플 앱에서 Google의 profile, emailopenid 범위는 AuthenticationBuilder에서 AddGoogle이 호출되면 프레임워크에 의해 자동으로 추가됩니다. 앱에 추가 범위가 필요한 경우 옵션에 추가합니다. 다음 예제에서는 사용자의 생일을 검색하기 위해 Google https://www.googleapis.com/auth/user.birthday.read 범위를 추가합니다.

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

사용자 데이터 키 매핑 및 클레임 만들기

공급자 옵션에서 앱 ID가 로그인 시 읽을 JS외부 공급자의 JSON 사용자 데이터에 있는 각 키/하위 키에 대해 MapJsonKey 또는 MapJsonSubKey를 지정합니다. 클레임 형식에 대한 자세한 내용은 ClaimTypes를 참조하세요.

샘플 앱은 Google 사용자 데이터의 urn:google:localeurn:google:picture 키에서 로케일(locale) 및 사진(picture) 클레임을 만듭니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync에서 IdentityUser(ApplicationUser)는 SignInAsync를 통해 앱에 로그인됩니다. 로그인 프로세스 중에 UserManager<TUser>Principal에서 사용할 수 있는 사용자 데이터에 대한 ApplicationUser 클레임을 저장할 수 있습니다.

샘플 앱 OnPostConfirmationAsync 에서 (Account/ExternalLogin.cshtml.cs)는 다음에 대한 클레임을 포함하여 로그인ApplicationUser에 대한 로캘(urn:google:locale) 및 그림(urn:google:picture) 클레임을 GivenName설정합니다.

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

기본적으로 사용자의 클레임은 인증 cookie에 저장됩니다. 인증 cookie가 너무 크면 다음과 같은 이유로 앱이 실패할 수 있습니다.

  • 브라우저에서 cookie 헤더가 너무 긴 것을 감지합니다.
  • 요청의 전체 크기가 너무 큽니다.

사용자 요청을 처리하는 데 많은 양의 사용자 데이터가 필요한 경우:

  • 요청 처리에 대한 사용자 클레임의 수와 크기를 앱에 필요한 것으로 제한합니다.
  • Cookie 인증 미들웨어의 SessionStore에 대한 사용자 지정 ITicketStore를 사용하여 요청 간에 ID를 저장합니다. 작은 세션 식별자 키만 클라이언트에 보내는 동안 서버에서 대량의 ID 정보를 유지합니다.

액세스 토큰 저장

SaveTokens는 성공적인 권한 부여 후에 액세스 및 새로 고침 토큰을 AuthenticationProperties에 저장해야 하는지 여부를 정의합니다. SaveTokens는 최종 인증 cookie의 크기를 줄이기 위해 기본적으로 false로 설정됩니다.

샘플 앱은 GoogleOptions에서 SaveTokens 값을 true로 설정합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync을 실행하는 경우 ApplicationUserAuthenticationProperties 외부 공급자에 액세스 토큰 (ExternalLoginInfo.AuthenticationTokens)을 저장 합니다.

샘플 앱은 액세스 토큰 OnPostConfirmationAsync 을 (새 사용자 등록) 및 OnGetCallbackAsync (이전에 등록된 사용자)에 저장합니다.Account/ExternalLogin.cshtml.cs

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

참고 항목

서버 쪽 앱의 구성 요소에 토큰을 Razor 전달하는 방법에 대한 자세한 내용은 서버 쪽 Blazor ASP.NET Core Blazor 추가 보안 시나리오를 참조하세요.

추가 사용자 지정 토큰을 추가하는 방법

SaveTokens의 일부로 저장되는 사용자 지정 토큰을 추가하는 방법을 보여주려면 샘플 앱은 TicketCreatedAuthenticationToken.Name에 대해 현재 DateTime에서 AuthenticationToken를 추가합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

클레임 만들기 및 추가

프레임워크는 컬렉션에 클레임을 만들고 추가하기 위한 일반적인 작업 및 확장 메서드를 제공합니다. 자세한 내용은 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions를 참조하세요.

사용자는 ClaimAction에서 파생되고 추상 Run 메서드를 구현하여 사용자 지정 작업을 정의할 수 있습니다.

자세한 내용은 Microsoft.AspNetCore.Authentication.OAuth.Claims를 참조하세요.

사용자 클레임 추가 및 업데이트

클레임은 로그인할 때가 아니라 처음 등록할 때 외부 공급자에서 사용자 데이터베이스로 복사됩니다. 사용자가 앱을 사용하도록 등록한 후 앱에서 추가 클레임을 사용하도록 설정한 경우 사용자에 대해 SignInManager.RefreshSignInAsync를 호출하여 새 인증 cookie를 강제로 생성합니다.

테스트 사용자 계정으로 작업하는 개발 환경에서는 사용자 계정을 삭제하고 다시 만들면 됩니다. 프로덕션 시스템의 경우 앱에 추가된 새 클레임을 사용자 계정에 백필할 수 있습니다. Areas/Pages/Identity/Account/Manage의 앱에 ExternalLogin 페이지를 스캐폴딩한 후 ExternalLogin.cshtml.cs 파일의 ExternalLoginModel에 다음 코드를 추가합니다.

추가된 클레임의 사전을 추가합니다. 사전 키를 사용하여 클레임 형식을 보관하고 값을 사용하여 기본값을 유지합니다. 다음 줄을 클래스 맨 위에 추가합니다. 다음 예제에서는 일반 헤드샷 이미지를 기본값으로 사용하여 사용자의 Google 사진에 대해 하나의 클레임이 추가되었다고 가정합니다.

private readonly IReadOnlyDictionary<string, string> _claimsToSync = 
    new Dictionary<string, string>()
    {
        { "urn:google:picture", "https://localhost:5001/headshot.png" },
    };

OnGetCallbackAsync 메서드의 기본 코드를 다음 코드로 바꿉니다. 코드는 클레임 사전을 반복합니다. 클레임은 각 사용자에 대해 추가(백필)되거나 업데이트됩니다. 클레임이 추가되거나 업데이트되면 기존 인증 속성(AuthenticationProperties)을 유지하면서 SignInManager<TUser>를 사용하여 사용자 로그인을 새로 고칩니다.

public async Task<IActionResult> OnGetCallbackAsync(
    string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");

    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";

        return RedirectToPage("./Login", new {ReturnUrl = returnUrl });
    }

    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a 
    // login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, 
        info.ProviderKey, isPersistent: false, bypassTwoFactor : true);

    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", 
            info.Principal.Identity.Name, info.LoginProvider);

        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider, 
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user, 
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key, 
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }

    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an 
        // account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;

        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }

        return Page();
    }
}

사용자가 로그인하는 동안 클레임이 변경되지만 백필 단계가 필요하지 않은 경우에도 비슷한 접근 방식이 수행됩니다. 사용자의 클레임을 업데이트하려면 사용자에 대해 다음을 호출합니다.

클레임 작업 및 클레임 제거

ClaimActionCollection.Remove(String)는 컬렉션에서 지정된 ClaimType에 대한 모든 클레임 동작을 제거합니다. ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String)은 ID에서 지정된 ClaimType의 클레임을 삭제합니다. DeleteClaim은 주로 OIDC(OpenID Connect)와 함께 사용하여 프로토콜 생성 클레임을 제거합니다.

샘플 앱 출력

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

프록시 또는 부하 분산 장치를 사용하여 요청 정보 전달

앱이 프록시 서버 또는 부하 분산 장치 뒤에 배포되는 경우 원래 요청 정보의 일부가 요청 헤더로 앱에 전달될 수 있습니다. 이 정보에는 일반적으로 보안 요청 체계(https), 호스트 및 클라이언트 IP 주소가 포함됩니다. 앱은 원래 요청 정보를 검색 및 사용하기 위해 이 요청 헤더를 자동으로 읽지 않습니다.

이 체계는 외부 공급자의 인증 흐름에 영향을 주는 링크 생성에 사용됩니다. 보안 체계(https)가 손실되면 앱이 잘못되고 안전하지 않은 리디렉션 URL을 생성합니다.

전달된 헤더 미들웨어를 사용하여 요청 처리를 위한 원본 요청 정보를 앱에 제공합니다.

자세한 내용은 프록시 서버 및 부하 분산 장치를 사용하도록 ASP.NET Core 구성을 참조하세요.

ASP.NET Core 앱은 Facebook, Google, Microsoft 및 Twitter와 같은 외부 인증 공급자로부터 추가 클레임 및 토큰을 설정할 수 있습니다. 각 공급자는 해당 플랫폼에서 사용자에 대한 다양한 정보를 제공하지만 사용자 데이터를 수신하고 추가 클레임으로 변환하는 패턴은 동일합니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

필수 조건

앱에서 지원할 외부 인증 공급자를 결정합니다. 각 공급자에 대해 앱을 등록하고 클라이언트 ID 및 클라이언트 암호를 얻습니다. 자세한 내용은 ASP.NET Core Facebook 및 Google 인증을 참조하세요. 샘플 앱은 Google 인증 공급자를 사용합니다.

클라이언트 ID 및 클라이언트 암호 설정

OAuth 인증 공급자는 클라이언트 ID 및 클라이언트 암호를 사용하여 앱과 트러스트 관계를 설정합니다. 클라이언트 ID 및 클라이언트 암호 값은 앱이 공급자에 등록될 때 외부 인증 공급자에 의해 앱에 대해 생성됩니다. 앱에서 사용하는 각 외부 공급자는 공급자의 클라이언트 ID 및 클라이언트 암호와 독립적으로 구성되어야 합니다. 자세한 내용은 시나리오에 적용되는 외부 인증 공급자 항목을 참조하세요.

샘플 앱은 Google에서 제공하는 클라이언트 ID 및 클라이언트 암호를 사용하여 Google 인증 공급자를 구성합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

인증 범위 설정

Scope를 지정하여 공급자에서 검색할 권한 목록을 지정합니다. 일반 외부 공급자에 대한 인증 범위는 다음 표에 나와 있습니다.

Provider Scope
Facebook https://www.facebook.com/dialog/oauth
Google https://www.googleapis.com/auth/userinfo.profile
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

샘플 앱에서 Google의 userinfo.profile 범위는 AuthenticationBuilder에서 AddGoogle이 호출되면 프레임워크에 의해 자동으로 추가됩니다. 앱에 추가 범위가 필요한 경우 옵션에 추가합니다. 다음 예제에서는 사용자의 생일을 검색하기 위해 Google https://www.googleapis.com/auth/user.birthday.read 범위를 추가합니다.

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

사용자 데이터 키 매핑 및 클레임 만들기

공급자 옵션에서 앱 ID가 로그인 시 읽을 JS외부 공급자의 JSON 사용자 데이터에 있는 각 키/하위 키에 대해 MapJsonKey 또는 MapJsonSubKey를 지정합니다. 클레임 형식에 대한 자세한 내용은 ClaimTypes를 참조하세요.

샘플 앱은 Google 사용자 데이터의 urn:google:localeurn:google:picture 키에서 로케일(locale) 및 사진(picture) 클레임을 만듭니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync에서 IdentityUser(ApplicationUser)는 SignInAsync를 통해 앱에 로그인됩니다. 로그인 프로세스 중에 UserManager<TUser>Principal에서 사용할 수 있는 사용자 데이터에 대한 ApplicationUser 클레임을 저장할 수 있습니다.

샘플 앱 OnPostConfirmationAsync 에서 (Account/ExternalLogin.cshtml.cs)는 다음에 대한 클레임을 포함하여 로그인ApplicationUser에 대한 로캘(urn:google:locale) 및 그림(urn:google:picture) 클레임을 GivenName설정합니다.

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

기본적으로 사용자의 클레임은 인증 cookie에 저장됩니다. 인증 cookie가 너무 크면 다음과 같은 이유로 앱이 실패할 수 있습니다.

  • 브라우저에서 cookie 헤더가 너무 긴 것을 감지합니다.
  • 요청의 전체 크기가 너무 큽니다.

사용자 요청을 처리하는 데 많은 양의 사용자 데이터가 필요한 경우:

  • 요청 처리에 대한 사용자 클레임의 수와 크기를 앱에 필요한 것으로 제한합니다.
  • Cookie 인증 미들웨어의 SessionStore에 대한 사용자 지정 ITicketStore를 사용하여 요청 간에 ID를 저장합니다. 작은 세션 식별자 키만 클라이언트에 보내는 동안 서버에서 대량의 ID 정보를 유지합니다.

액세스 토큰 저장

SaveTokens는 성공적인 권한 부여 후에 액세스 및 새로 고침 토큰을 AuthenticationProperties에 저장해야 하는지 여부를 정의합니다. SaveTokens는 최종 인증 cookie의 크기를 줄이기 위해 기본적으로 false로 설정됩니다.

샘플 앱은 GoogleOptions에서 SaveTokens 값을 true로 설정합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync을 실행하는 경우 ApplicationUserAuthenticationProperties 외부 공급자에 액세스 토큰 (ExternalLoginInfo.AuthenticationTokens)을 저장 합니다.

샘플 앱은 액세스 토큰 OnPostConfirmationAsync 을 (새 사용자 등록) 및 OnGetCallbackAsync (이전에 등록된 사용자)에 저장합니다.Account/ExternalLogin.cshtml.cs

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

추가 사용자 지정 토큰을 추가하는 방법

SaveTokens의 일부로 저장되는 사용자 지정 토큰을 추가하는 방법을 보여주려면 샘플 앱은 TicketCreatedAuthenticationToken.Name에 대해 현재 DateTime에서 AuthenticationToken를 추가합니다.

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

클레임 만들기 및 추가

프레임워크는 컬렉션에 클레임을 만들고 추가하기 위한 일반적인 작업 및 확장 메서드를 제공합니다. 자세한 내용은 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions를 참조하세요.

사용자는 ClaimAction에서 파생되고 추상 Run 메서드를 구현하여 사용자 지정 작업을 정의할 수 있습니다.

자세한 내용은 Microsoft.AspNetCore.Authentication.OAuth.Claims를 참조하세요.

클레임 작업 및 클레임 제거

ClaimActionCollection.Remove(String)는 컬렉션에서 지정된 ClaimType에 대한 모든 클레임 동작을 제거합니다. ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String)은 ID에서 지정된 ClaimType의 클레임을 삭제합니다. DeleteClaim은 주로 OIDC(OpenID Connect)와 함께 사용하여 프로토콜 생성 클레임을 제거합니다.

샘플 앱 출력

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

프록시 또는 부하 분산 장치를 사용하여 요청 정보 전달

앱이 프록시 서버 또는 부하 분산 장치 뒤에 배포되는 경우 원래 요청 정보의 일부가 요청 헤더로 앱에 전달될 수 있습니다. 이 정보에는 일반적으로 보안 요청 체계(https), 호스트 및 클라이언트 IP 주소가 포함됩니다. 앱은 원래 요청 정보를 검색 및 사용하기 위해 이 요청 헤더를 자동으로 읽지 않습니다.

이 체계는 외부 공급자의 인증 흐름에 영향을 주는 링크 생성에 사용됩니다. 보안 체계(https)가 손실되면 앱이 잘못되고 안전하지 않은 리디렉션 URL을 생성합니다.

전달된 헤더 미들웨어를 사용하여 요청 처리를 위한 원본 요청 정보를 앱에 제공합니다.

자세한 내용은 프록시 서버 및 부하 분산 장치를 사용하도록 ASP.NET Core 구성을 참조하세요.

추가 리소스

  • dotnet/AspNetCore 엔지니어링 SocialSample 앱: 연결된 샘플 앱은 dotnet/AspNetCore GitHub 리포지토리main 엔지니어링 분기에 있습니다. main 분기에는 ASP.NET Core의 다음 릴리스에 대해 활성 개발 중인 코드가 포함되어 있습니다. 릴리스된 ASP.NET Core의 버전에 대한 샘플 앱 버전을 보려면 분기 드롭다운 목록을 사용하여 릴리스 분기(예: release/{X.Y})를 선택합니다.