Dreamine.Identity 1.0.0.0
Dreamine.Identity 프로젝트의 API와 구성 요소를 제공합니다.
로딩중...
검색중...
일치하는것 없음
DreamineIdentityExtensions.cs
이 파일의 문서화 페이지로 가기
1using System.Security.Claims;
2using AspNet.Security.OAuth.Naver;
3using Dreamine.Database.Abstractions;
4using Dreamine.Database.Sqlite;
5#if WINDOWS
6using Dreamine.Hybrid.Wpf.Hosting;
7#endif
10using Microsoft.AspNetCore.Authentication;
11using Microsoft.AspNetCore.Authentication.Cookies;
12using Microsoft.AspNetCore.Authentication.Google;
13using Microsoft.AspNetCore.Authentication.OAuth;
14using Microsoft.AspNetCore.Builder;
15using Microsoft.AspNetCore.Components.Authorization;
16using Microsoft.AspNetCore.DataProtection;
17using Microsoft.AspNetCore.Http;
18using Microsoft.AspNetCore.HttpOverrides;
19using Microsoft.AspNetCore.Routing;
20using Microsoft.Extensions.DependencyInjection;
21using System.Net.Http.Headers;
22using System.Text.Json;
23
24namespace Dreamine.Identity;
25
34public static class DreamineIdentityExtensions
35{
44 public const string UserIdClaimType = "dreamine:userid";
45
54 public const string ProviderClaimType = "dreamine:provider";
55
64 private const string ProviderGoogle = "Google";
73 private const string ProviderNaver = "Naver";
82 private const string ProviderKakao = "Kakao";
83
124 public static IServiceCollection AddDreamineIdentityWpfHost(this IServiceCollection services)
125 {
126 ArgumentNullException.ThrowIfNull(services);
127 services.AddAuthorizationCore();
128 services.AddCascadingAuthenticationState();
129 services.AddScoped<AuthenticationStateProvider, AnonymousAuthenticationStateProvider>();
130 return services;
131 }
132
133#if WINDOWS
197 public static DreamineBlazorServerHostOptions AddDreamineIdentity(
198 this DreamineBlazorServerHostOptions options,
199 AuthOptions authOptions,
200 string databasePath)
201 {
202 ArgumentNullException.ThrowIfNull(options);
203 ArgumentNullException.ThrowIfNull(authOptions);
204 ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
205
206 var previousConfigure = options.ConfigureServices;
207 options.ConfigureServices = services =>
208 {
209 previousConfigure?.Invoke(services);
210 RegisterAuthServices(services, authOptions, databasePath);
211 services.Configure<ForwardedHeadersOptions>(forwarded =>
212 {
213 forwarded.ForwardedHeaders =
214 ForwardedHeaders.XForwardedFor |
215 ForwardedHeaders.XForwardedProto |
216 ForwardedHeaders.XForwardedHost;
217 forwarded.KnownNetworks.Clear();
218 forwarded.KnownProxies.Clear();
219 });
220 };
221
222 var previousPipeline = options.ConfigurePipeline;
223 options.ConfigurePipeline = app =>
224 {
225 app.UseForwardedHeaders();
226 previousPipeline?.Invoke(app);
227 app.UseAuthentication();
229 };
230
231 var previousAfterRouting = options.ConfigurePipelineAfterRouting;
232 options.ConfigurePipelineAfterRouting = app =>
233 {
234 previousAfterRouting?.Invoke(app);
235 app.UseAuthorization();
236 app.MapAuthEndpoints();
237 };
238
239 return options;
240 }
241#endif
242
307 public static IServiceCollection AddDreamineIdentityWeb(
308 this IServiceCollection services,
309 AuthOptions authOptions,
310 string databasePath)
311 {
312 ArgumentNullException.ThrowIfNull(services);
313 ArgumentNullException.ThrowIfNull(authOptions);
314 ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
315
316 RegisterAuthServices(services, authOptions, databasePath);
317 services.Configure<ForwardedHeadersOptions>(forwarded =>
318 {
319 forwarded.ForwardedHeaders =
320 ForwardedHeaders.XForwardedFor |
321 ForwardedHeaders.XForwardedProto |
322 ForwardedHeaders.XForwardedHost;
323 forwarded.KnownNetworks.Clear();
324 forwarded.KnownProxies.Clear();
325 });
326
327 return services;
328 }
329
362 public static IEndpointRouteBuilder MapDreamineIdentityEndpoints(this IEndpointRouteBuilder endpoints)
363 {
364 ArgumentNullException.ThrowIfNull(endpoints);
365 endpoints.MapAuthEndpoints();
366 return endpoints;
367 }
368
401 private static void RegisterAuthServices(
402 IServiceCollection services,
403 AuthOptions authOptions,
404 string databasePath)
405 {
406 Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
407
408 var provider = new SqliteDatabaseProvider($"Data Source={databasePath}");
409 services.AddSingleton<IDatabaseProvider>(provider);
410 services.AddSingleton<IUserStore, SqliteUserStore>();
411
412 if (!string.IsNullOrWhiteSpace(authOptions.DataProtectionKeysPath))
413 {
414 Directory.CreateDirectory(authOptions.DataProtectionKeysPath);
415 services.AddDataProtection()
416 .PersistKeysToFileSystem(new DirectoryInfo(authOptions.DataProtectionKeysPath))
417 .SetApplicationName(string.IsNullOrWhiteSpace(authOptions.DataProtectionApplicationName)
418 ? "Dreamine.Identity"
419 : authOptions.DataProtectionApplicationName);
420 }
421
422 var authBuilder = services
423 .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
424 .AddCookie(cookie =>
425 {
426 cookie.LoginPath = "/_identity/login";
427 cookie.LogoutPath = "/_identity/signout";
428 cookie.AccessDeniedPath = "/";
429 cookie.ExpireTimeSpan = TimeSpan.FromDays(30);
430 cookie.SlidingExpiration = true;
431 cookie.Cookie.Name = string.IsNullOrWhiteSpace(authOptions.CookieName)
432 ? ".Dreamine.Identity"
433 : authOptions.CookieName;
434 if (!string.IsNullOrWhiteSpace(authOptions.CookieDomain))
435 {
436 cookie.Cookie.Domain = authOptions.CookieDomain;
437 }
438 });
439
440 if (authOptions.Google.IsConfigured)
441 {
442 authBuilder.AddGoogle(google =>
443 {
444 google.ClientId = authOptions.Google.ClientId;
445 google.ClientSecret = authOptions.Google.ClientSecret;
446 google.CallbackPath = "/signin-google";
447 google.SaveTokens = false;
448 google.Events.OnCreatingTicket = context => OnCreatingTicketAsync(context, ProviderGoogle);
449 });
450 }
451
452 if (authOptions.Naver.IsConfigured)
453 {
454 authBuilder.AddNaver(naver =>
455 {
456 naver.ClientId = authOptions.Naver.ClientId;
457 naver.ClientSecret = authOptions.Naver.ClientSecret;
458 naver.CallbackPath = "/signin-naver";
459 naver.SaveTokens = false;
460 naver.Events.OnCreatingTicket = context => OnCreatingTicketAsync(context, ProviderNaver);
461 });
462 }
463
464 if (authOptions.Kakao.IsConfigured)
465 {
466 authBuilder.AddOAuth(ProviderKakao, kakao =>
467 {
468 kakao.ClientId = authOptions.Kakao.ClientId;
469 kakao.ClientSecret = authOptions.Kakao.ClientSecret;
470 kakao.CallbackPath = "/signin-kakao";
471 kakao.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
472 kakao.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
473 kakao.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
474 kakao.SaveTokens = false;
475 // 카카오 개인 앱은 account_email 을 요청할 수 없음 (권한 없음 → KOE205).
476 // 이메일이 필요하면 카카오 개발자 콘솔에서 "추가 기능 신청" 후 스코프 추가.
477 kakao.Scope.Add("profile_nickname");
478 kakao.Scope.Add("profile_image");
479 kakao.Events.OnCreatingTicket = OnKakaoCreatingTicketAsync;
480 });
481 }
482
483 services.AddAuthorization();
484 services.AddCascadingAuthenticationState();
485 }
486
519 private static async Task OnCreatingTicketAsync(OAuthCreatingTicketContext context, string providerName)
520 {
521 var principal = context.Principal;
522 if (principal is null)
523 {
524 return;
525 }
526
527 var providerKey = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
528 var email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
529 var displayName = principal.FindFirstValue(ClaimTypes.Name)
530 ?? principal.FindFirstValue(ClaimTypes.GivenName)
531 ?? string.Empty;
532 var avatarUrl = principal.FindFirstValue("urn:google:picture")
533 ?? principal.FindFirstValue("picture")
534 ?? principal.FindFirstValue("urn:naver:profile_image")
535 ?? principal.FindFirstValue("profileimage")
536 ?? string.Empty;
537
538 if (string.IsNullOrEmpty(providerKey))
539 {
540 return;
541 }
542
544 context,
545 providerName,
546 providerKey,
547 email,
548 displayName,
549 avatarUrl).ConfigureAwait(false);
550 }
551
584 private static async Task OnKakaoCreatingTicketAsync(OAuthCreatingTicketContext context)
585 {
586 if (string.IsNullOrWhiteSpace(context.AccessToken) || context.Principal?.Identity is not ClaimsIdentity identity)
587 {
588 return;
589 }
590
591 using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
592 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
593 request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
594
595 using var response = await context.Backchannel.SendAsync(
596 request,
597 context.HttpContext.RequestAborted).ConfigureAwait(false);
598 response.EnsureSuccessStatusCode();
599
600 await using var stream = await response.Content.ReadAsStreamAsync(context.HttpContext.RequestAborted)
601 .ConfigureAwait(false);
602 using var document = await JsonDocument.ParseAsync(
603 stream,
604 cancellationToken: context.HttpContext.RequestAborted).ConfigureAwait(false);
605
606 var root = document.RootElement;
607 var providerKey = ReadString(root, "id");
608 if (string.IsNullOrWhiteSpace(providerKey))
609 {
610 return;
611 }
612
613 var account = TryGetProperty(root, "kakao_account");
614 var profile = account.HasValue ? TryGetProperty(account.Value, "profile") : null;
615
616 var email = account.HasValue ? ReadString(account.Value, "email") : string.Empty;
617 var displayName = profile.HasValue ? ReadString(profile.Value, "nickname") : string.Empty;
618 var avatarUrl = profile.HasValue ? ReadString(profile.Value, "profile_image_url") : string.Empty;
619
620 AddClaimIfNotEmpty(identity, ClaimTypes.NameIdentifier, providerKey);
621 AddClaimIfNotEmpty(identity, ClaimTypes.Email, email);
622 AddClaimIfNotEmpty(identity, ClaimTypes.Name, displayName);
623 AddClaimIfNotEmpty(identity, "picture", avatarUrl);
624
626 context,
628 providerKey,
629 email,
630 displayName,
631 avatarUrl).ConfigureAwait(false);
632 }
633
698 private static async Task UpsertExternalUserAsync(
699 OAuthCreatingTicketContext context,
700 string providerName,
701 string providerKey,
702 string email,
703 string displayName,
704 string avatarUrl)
705 {
706 var principal = context.Principal;
707 if (principal?.Identity is not ClaimsIdentity identity)
708 {
709 return;
710 }
711
712 var userStore = context.HttpContext.RequestServices.GetRequiredService<IUserStore>();
713 var user = await userStore.UpsertAsync(
714 providerName,
715 providerKey,
716 email,
717 displayName,
718 avatarUrl,
719 context.HttpContext.RequestAborted).ConfigureAwait(false);
720
721 identity.AddClaim(new Claim(UserIdClaimType, user.Id.ToString()));
722 identity.AddClaim(new Claim(ProviderClaimType, providerName));
723 }
724
757 private static JsonElement? TryGetProperty(JsonElement element, string name) =>
758 element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value)
759 ? value
760 : null;
761
794 private static string ReadString(JsonElement element, string name)
795 {
796 if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var value))
797 {
798 return string.Empty;
799 }
800
801 return value.ValueKind switch
802 {
803 JsonValueKind.String => value.GetString() ?? string.Empty,
804 JsonValueKind.Number => value.GetRawText(),
805 _ => string.Empty
806 };
807 }
808
841 private static void AddClaimIfNotEmpty(ClaimsIdentity identity, string type, string value)
842 {
843 if (!string.IsNullOrWhiteSpace(value) && !identity.HasClaim(claim => claim.Type == type))
844 {
845 identity.AddClaim(new Claim(type, value));
846 }
847 }
848
881 private static async Task EnforceRequiredConsentsAsync(HttpContext context, RequestDelegate next)
882 {
883 if (ShouldSkipConsentGate(context.Request.Path) ||
884 context.User.Identity?.IsAuthenticated != true)
885 {
886 await next(context).ConfigureAwait(false);
887 return;
888 }
889
890 var userIdValue = context.User.FindFirstValue(UserIdClaimType);
891 if (!long.TryParse(
892 userIdValue,
893 System.Globalization.NumberStyles.None,
894 System.Globalization.CultureInfo.InvariantCulture,
895 out var userId))
896 {
897 await next(context).ConfigureAwait(false);
898 return;
899 }
900
901 var userStore = context.RequestServices.GetService<IUserStore>();
902 if (userStore is null)
903 {
904 await next(context).ConfigureAwait(false);
905 return;
906 }
907
908 var user = await userStore.GetByIdAsync(userId, context.RequestAborted).ConfigureAwait(false);
909 if (RequiredConsentPolicy.HasRequiredConsents(user))
910 {
911 await next(context).ConfigureAwait(false);
912 return;
913 }
914
915 var returnUrl = Uri.EscapeDataString(BuildCurrentUrl(context.Request));
916 context.Response.Redirect($"/_identity/consent?returnUrl={returnUrl}");
917 }
918
943 private static bool ShouldSkipConsentGate(PathString path)
944 {
945 var value = path.Value ?? "/";
946 if (value is "/" or "")
947 {
948 return false;
949 }
950
951 if (Path.HasExtension(value))
952 {
953 return true;
954 }
955
956 return value.StartsWith("/_identity", StringComparison.OrdinalIgnoreCase) ||
957 value.StartsWith("/signin", StringComparison.OrdinalIgnoreCase) ||
958 value.StartsWith("/login", StringComparison.OrdinalIgnoreCase) ||
959 value.StartsWith("/signup", StringComparison.OrdinalIgnoreCase) ||
960 value.StartsWith("/consent", StringComparison.OrdinalIgnoreCase) ||
961 value.StartsWith("/signout", StringComparison.OrdinalIgnoreCase) ||
962 value.StartsWith("/privacy", StringComparison.OrdinalIgnoreCase) ||
963 value.StartsWith("/terms", StringComparison.OrdinalIgnoreCase) ||
964 value.StartsWith("/_blazor", StringComparison.OrdinalIgnoreCase) ||
965 value.StartsWith("/_framework", StringComparison.OrdinalIgnoreCase) ||
966 value.StartsWith("/css", StringComparison.OrdinalIgnoreCase) ||
967 value.StartsWith("/js", StringComparison.OrdinalIgnoreCase) ||
968 value.StartsWith("/img", StringComparison.OrdinalIgnoreCase) ||
969 value.StartsWith("/bootstrap", StringComparison.OrdinalIgnoreCase);
970 }
971
996 private static string BuildCurrentUrl(HttpRequest request)
997 {
998 var path = request.PathBase.Add(request.Path).ToString();
999 var query = request.QueryString.HasValue ? request.QueryString.Value : string.Empty;
1000 return string.IsNullOrEmpty(path) ? $"/{query}" : $"{path}{query}";
1001 }
1002}
static async Task OnCreatingTicketAsync(OAuthCreatingTicketContext context, string providerName)
static ? JsonElement TryGetProperty(JsonElement element, string name)
static string ReadString(JsonElement element, string name)
static async Task EnforceRequiredConsentsAsync(HttpContext context, RequestDelegate next)
static void RegisterAuthServices(IServiceCollection services, AuthOptions authOptions, string databasePath)
static async Task UpsertExternalUserAsync(OAuthCreatingTicketContext context, string providerName, string providerKey, string email, string displayName, string avatarUrl)
static IServiceCollection AddDreamineIdentityWeb(this IServiceCollection services, AuthOptions authOptions, string databasePath)
static IServiceCollection AddDreamineIdentityWpfHost(this IServiceCollection services)
static void AddClaimIfNotEmpty(ClaimsIdentity identity, string type, string value)
static IEndpointRouteBuilder MapDreamineIdentityEndpoints(this IEndpointRouteBuilder endpoints)
static async Task OnKakaoCreatingTicketAsync(OAuthCreatingTicketContext context)
Task< AuthUser?> GetByIdAsync(long id, CancellationToken cancellationToken=default)
Task< AuthUser > UpsertAsync(string provider, string providerKey, string email, string displayName, string avatarUrl, CancellationToken cancellationToken=default)