Dreamine.Identity 1.0.0.0
Dreamine.Identity 프로젝트의 API와 구성 요소를 제공합니다.
로딩중...
검색중...
일치하는것 없음
AuthEndpoints.cs
이 파일의 문서화 페이지로 가기
1using AspNet.Security.OAuth.Naver;
2using Microsoft.AspNetCore.Authentication;
3using Microsoft.AspNetCore.Authentication.Cookies;
4using Microsoft.AspNetCore.Authentication.Google;
5using Microsoft.AspNetCore.Builder;
6using Microsoft.AspNetCore.Http;
7using Microsoft.AspNetCore.Routing;
8using System.Net;
9using System.Security.Claims;
10using System.Text;
12
14
23internal static class AuthEndpoints
24{
33 private const string LocalProvider = "Local";
34
51 public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
52 {
53 endpoints.MapGet("/_identity/login", (HttpContext http, string? returnUrl, string? mode, string? message, string? error) =>
54 Results.Content(
55 BuildLoginHtml(SafeReturnUrl(returnUrl), mode, message, error, "/_identity"),
56 "text/html; charset=utf-8"));
57
58 endpoints.MapGet("/login", (HttpContext http, string? returnUrl, string? mode, string? message, string? error) =>
59 Results.Content(
60 BuildLoginHtml(SafeReturnUrl(returnUrl), mode, message, error),
61 "text/html; charset=utf-8"));
62
63 endpoints.MapPost("/_identity/login", async (HttpContext http, IUserStore userStore) =>
64 await HandleLocalLoginAsync(http, userStore, "/_identity").ConfigureAwait(false));
65
66 endpoints.MapPost("/login", async (HttpContext http, IUserStore userStore) =>
67 await HandleLocalLoginAsync(http, userStore, string.Empty).ConfigureAwait(false));
68
69 endpoints.MapPost("/_identity/signup", async (HttpContext http, IUserStore userStore) =>
70 await HandleSignupAsync(http, userStore, "/_identity").ConfigureAwait(false));
71
72 endpoints.MapPost("/signup", async (HttpContext http, IUserStore userStore) =>
73 await HandleSignupAsync(http, userStore, string.Empty).ConfigureAwait(false));
74
75 endpoints.MapGet("/signin/google", (HttpContext http, string? returnUrl) =>
76 {
77 var safeReturnUrl = SafeReturnUrl(returnUrl);
78 if (IsEmbeddedMobileBrowser(http.Request.Headers.UserAgent.ToString()))
79 {
80 return Results.Redirect($"/_identity/login?returnUrl={Url(safeReturnUrl)}&error={Url("Google 로그인은 카카오톡/네이버앱 같은 앱 내부 브라우저에서 차단됩니다. 오른쪽 위 메뉴에서 '브라우저로 열기'를 선택한 뒤 다시 시도해 주세요. Naver/Kakao 로그인은 현재 화면에서도 사용할 수 있습니다.")}");
81 }
82
83 return Results.Challenge(
84 new AuthenticationProperties { RedirectUri = safeReturnUrl },
85 new[] { GoogleDefaults.AuthenticationScheme });
86 });
87
88 endpoints.MapGet("/signin/naver", (HttpContext http, string? returnUrl) =>
89 Results.Challenge(
90 new AuthenticationProperties { RedirectUri = SafeReturnUrl(returnUrl) },
91 new[] { NaverAuthenticationDefaults.AuthenticationScheme }));
92
93 endpoints.MapGet("/signin/kakao", (HttpContext http, string? returnUrl) =>
94 Results.Challenge(
95 new AuthenticationProperties { RedirectUri = SafeReturnUrl(returnUrl) },
96 new[] { "Kakao" }));
97
98 endpoints.MapGet("/_identity/account", async (HttpContext http, IUserStore userStore, string? returnUrl, string? message, string? error) =>
99 await HandleAccountPageAsync(http, userStore, returnUrl, message, error, "/_identity").ConfigureAwait(false));
100
101 endpoints.MapGet("/account", async (HttpContext http, IUserStore userStore, string? returnUrl, string? message, string? error) =>
102 await HandleAccountPageAsync(http, userStore, returnUrl, message, error, string.Empty).ConfigureAwait(false));
103
104 endpoints.MapGet("/_identity/consent", async (HttpContext http, IUserStore userStore, string? returnUrl, string? error) =>
105 await HandleConsentPageAsync(http, userStore, returnUrl, error, "/_identity").ConfigureAwait(false));
106
107 endpoints.MapGet("/consent", async (HttpContext http, IUserStore userStore, string? returnUrl, string? error) =>
108 await HandleConsentPageAsync(http, userStore, returnUrl, error, string.Empty).ConfigureAwait(false));
109
110 endpoints.MapPost("/_identity/account", async (HttpContext http, IUserStore userStore) =>
111 await HandleAccountPostAsync(http, userStore, "/_identity").ConfigureAwait(false));
112
113 endpoints.MapPost("/account", async (HttpContext http, IUserStore userStore) =>
114 await HandleAccountPostAsync(http, userStore, string.Empty).ConfigureAwait(false));
115
116 endpoints.MapPost("/_identity/consent", async (HttpContext http, IUserStore userStore) =>
117 await HandleConsentPostAsync(http, userStore, "/_identity").ConfigureAwait(false));
118
119 endpoints.MapPost("/consent", async (HttpContext http, IUserStore userStore) =>
120 await HandleConsentPostAsync(http, userStore, string.Empty).ConfigureAwait(false));
121
122 endpoints.MapGet("/_identity/signout", async (HttpContext http, string? returnUrl) =>
123 {
124 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
125 return Results.Redirect(SafeReturnUrl(returnUrl));
126 });
127
128 endpoints.MapGet("/signout", async (HttpContext http, string? returnUrl) =>
129 {
130 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
131 return Results.Redirect(SafeReturnUrl(returnUrl));
132 });
133 }
134
175 private static async Task<IResult> HandleLocalLoginAsync(HttpContext http, IUserStore userStore, string routePrefix)
176 {
177 var loginPath = $"{routePrefix}/login";
178 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
179 var returnUrl = SafeReturnUrl(form["returnUrl"]);
180 var email = form["email"].ToString();
181 var password = form["password"].ToString();
182
183 var user = await userStore.ValidateLocalAsync(email, password, http.RequestAborted)
184 .ConfigureAwait(false);
185 if (user is null)
186 {
187 return Results.Redirect($"{loginPath}?returnUrl={Url(returnUrl)}&error={Url("직접 회원가입한 이메일/비밀번호가 올바르지 않습니다. Google, Naver, Kakao 계정은 아래 소셜 로그인 버튼을 사용해 주세요.")}");
188 }
189
190 await SignInAsync(http, user).ConfigureAwait(false);
191 return Results.Redirect(returnUrl);
192 }
193
234 private static async Task<IResult> HandleSignupAsync(HttpContext http, IUserStore userStore, string routePrefix)
235 {
236 var loginPath = $"{routePrefix}/login";
237 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
238 var returnUrl = SafeReturnUrl(form["returnUrl"]);
239 var email = form["email"].ToString();
240 var displayName = form["displayName"].ToString();
241 var password = form["password"].ToString();
242 var confirmPassword = form["confirmPassword"].ToString();
243
244 if (!HasAcceptedRequiredSignupConsents(form))
245 {
246 return Results.Redirect($"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url("필수 약관, 개인정보처리방침, 만 14세 이상 확인에 모두 동의해야 가입할 수 있습니다.")}");
247 }
248
249 if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
250 {
251 return Results.Redirect($"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url("비밀번호 확인이 일치하지 않습니다.")}");
252 }
253
254 try
255 {
256 var user = await userStore.CreateLocalAsync(email, displayName, password, http.RequestAborted)
257 .ConfigureAwait(false);
258 user = await userStore.AcceptRequiredConsentsAsync(user.Id, DateTime.UtcNow, http.RequestAborted)
259 .ConfigureAwait(false) ?? user;
260 await SignInAsync(http, user).ConfigureAwait(false);
261 return Results.Redirect(returnUrl);
262 }
263 catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
264 {
265 return Results.Redirect($"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url(ex.Message)}");
266 }
267 }
268
333 private static async Task<IResult> HandleAccountPageAsync(
334 HttpContext http,
335 IUserStore userStore,
336 string? returnUrl,
337 string? message,
338 string? error,
339 string routePrefix)
340 {
341 var loginPath = $"{routePrefix}/login";
342 var accountPath = $"{routePrefix}/account";
343 var userId = GetCurrentUserId(http);
344 if (userId is null)
345 {
346 return Results.Redirect($"{loginPath}?returnUrl={Url(accountPath)}");
347 }
348
349 var user = await userStore.GetByIdAsync(userId.Value, http.RequestAborted).ConfigureAwait(false);
350 if (user is null)
351 {
352 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
353 return Results.Redirect($"{loginPath}?returnUrl={Url(accountPath)}");
354 }
355
356 return Results.Content(
357 BuildAccountHtml(user, SafeReturnUrl(returnUrl), message, error, routePrefix),
358 "text/html; charset=utf-8");
359 }
360
401 private static async Task<IResult> HandleAccountPostAsync(HttpContext http, IUserStore userStore, string routePrefix)
402 {
403 var loginPath = $"{routePrefix}/login";
404 var accountPath = $"{routePrefix}/account";
405 var userId = GetCurrentUserId(http);
406 if (userId is null)
407 {
408 return Results.Redirect($"{loginPath}?returnUrl={Url(accountPath)}");
409 }
410
411 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
412 var returnUrl = SafeReturnUrl(form["returnUrl"]);
413 var accountAction = form["accountAction"].ToString();
414
415 try
416 {
417 AuthUser? user;
418 string message;
419 if (string.Equals(accountAction, "password", StringComparison.OrdinalIgnoreCase))
420 {
421 var currentPassword = form["currentPassword"].ToString();
422 var newPassword = form["newPassword"].ToString();
423 var confirmPassword = form["confirmPassword"].ToString();
424
425 if (!string.Equals(newPassword, confirmPassword, StringComparison.Ordinal))
426 {
427 return Results.Redirect($"{accountPath}?returnUrl={Url(returnUrl)}&error={Url("새 비밀번호 확인이 일치하지 않습니다.")}");
428 }
429
430 user = await userStore.ChangeLocalPasswordAsync(
431 userId.Value,
432 currentPassword,
433 newPassword,
434 http.RequestAborted)
435 .ConfigureAwait(false);
436 message = "비밀번호가 변경되었습니다.";
437 }
438 else
439 {
440 var displayName = form["displayName"].ToString();
441 user = await userStore.UpdateDisplayNameAsync(userId.Value, displayName, http.RequestAborted)
442 .ConfigureAwait(false);
443 message = "계정 정보가 저장되었습니다.";
444 }
445
446 if (user is null)
447 {
448 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
449 return Results.Redirect($"{loginPath}?returnUrl={Url(accountPath)}");
450 }
451
452 await SignInAsync(http, user).ConfigureAwait(false);
453 return Results.Redirect($"{accountPath}?returnUrl={Url(returnUrl)}&message={Url(message)}");
454 }
455 catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
456 {
457 return Results.Redirect($"{accountPath}?returnUrl={Url(returnUrl)}&error={Url(ex.Message)}");
458 }
459 }
460
517 private static async Task<IResult> HandleConsentPageAsync(
518 HttpContext http,
519 IUserStore userStore,
520 string? returnUrl,
521 string? error,
522 string routePrefix)
523 {
524 var loginPath = $"{routePrefix}/login";
525 var userId = GetCurrentUserId(http);
526 if (userId is null)
527 {
528 return Results.Redirect($"{loginPath}?returnUrl={Url($"{routePrefix}/consent")}");
529 }
530
531 var user = await userStore.GetByIdAsync(userId.Value, http.RequestAborted).ConfigureAwait(false);
532 if (user is null)
533 {
534 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
535 return Results.Redirect($"{loginPath}?returnUrl={Url($"{routePrefix}/consent")}");
536 }
537
538 var safeReturnUrl = SafeReturnUrl(returnUrl);
539 if (RequiredConsentPolicy.HasRequiredConsents(user))
540 {
541 return Results.Redirect(safeReturnUrl);
542 }
543
544 return Results.Content(
545 BuildConsentHtml(safeReturnUrl, error, routePrefix),
546 "text/html; charset=utf-8");
547 }
548
589 private static async Task<IResult> HandleConsentPostAsync(HttpContext http, IUserStore userStore, string routePrefix)
590 {
591 var loginPath = $"{routePrefix}/login";
592 var consentPath = $"{routePrefix}/consent";
593 var userId = GetCurrentUserId(http);
594 if (userId is null)
595 {
596 return Results.Redirect($"{loginPath}?returnUrl={Url(consentPath)}");
597 }
598
599 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
600 var returnUrl = SafeReturnUrl(form["returnUrl"]);
601 if (!HasAcceptedRequiredSignupConsents(form))
602 {
603 return Results.Redirect($"{consentPath}?returnUrl={Url(returnUrl)}&error={Url("필수 항목에 모두 동의해야 서비스를 계속 이용할 수 있습니다.")}");
604 }
605
606 var user = await userStore.AcceptRequiredConsentsAsync(userId.Value, DateTime.UtcNow, http.RequestAborted)
607 .ConfigureAwait(false);
608 if (user is null)
609 {
610 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
611 return Results.Redirect($"{loginPath}?returnUrl={Url(consentPath)}");
612 }
613
614 await SignInAsync(http, user).ConfigureAwait(false);
615 return Results.Redirect(returnUrl);
616 }
617
642 private static string SafeReturnUrl(string? returnUrl)
643 {
644 if (string.IsNullOrWhiteSpace(returnUrl))
645 {
646 return "/";
647 }
648
649 // Open-redirect 방지:
650 // - 같은 앱 내부 이동은 로컬 경로만 허용
651 // - 중앙 로그인 포털에서 서브 서비스로 돌아갈 수 있도록 codemaru.co.kr 계열만 절대 URL 허용
652 if (returnUrl.StartsWith("/", StringComparison.Ordinal)
653 && !returnUrl.StartsWith("//", StringComparison.Ordinal))
654 {
655 return returnUrl;
656 }
657
658 if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var uri)
659 || uri.Scheme is not ("https" or "http"))
660 {
661 return "/";
662 }
663
664 return IsAllowedReturnHost(uri.Host) ? uri.ToString() : "/";
665 }
666
691 private static bool IsAllowedReturnHost(string host)
692 {
693 if (string.IsNullOrWhiteSpace(host))
694 {
695 return false;
696 }
697
698 return string.Equals(host, "codemaru.co.kr", StringComparison.OrdinalIgnoreCase)
699 || host.EndsWith(".codemaru.co.kr", StringComparison.OrdinalIgnoreCase)
700 || string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
701 || string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)
702 || string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase);
703 }
704
737 private static async Task SignInAsync(HttpContext http, AuthUser user)
738 {
739 var claims = new List<Claim>
740 {
741 new(ClaimTypes.NameIdentifier, user.ProviderKey),
742 new(ClaimTypes.Email, user.Email),
743 new(ClaimTypes.Name, user.DisplayName),
744 new(DreamineIdentityExtensions.UserIdClaimType, user.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)),
746 };
747
748 var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
749 var principal = new ClaimsPrincipal(identity);
750 await http.SignInAsync(
751 CookieAuthenticationDefaults.AuthenticationScheme,
752 principal,
753 new AuthenticationProperties { IsPersistent = true }).ConfigureAwait(false);
754 }
755
780 private static long? GetCurrentUserId(HttpContext http)
781 {
782 var value = http.User.FindFirstValue(DreamineIdentityExtensions.UserIdClaimType);
783 return long.TryParse(
784 value,
785 System.Globalization.NumberStyles.None,
786 System.Globalization.CultureInfo.InvariantCulture,
787 out var id)
788 ? id
789 : null;
790 }
791
816 private static bool HasAcceptedRequiredSignupConsents(IFormCollection form) =>
817 IsChecked(form["termsAccepted"]) &&
818 IsChecked(form["privacyAccepted"]) &&
819 IsChecked(form["ageConfirmed"]);
820
845 private static bool IsChecked(string? value) =>
846 string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
847 string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
848 string.Equals(value, "1", StringComparison.OrdinalIgnoreCase);
849
906 private static string BuildLoginHtml(
907 string returnUrl,
908 string? mode,
909 string? message,
910 string? error,
911 string routePrefix = "")
912 {
913 var isSignup = string.Equals(mode, "signup", StringComparison.OrdinalIgnoreCase);
914 var title = isSignup ? "회원가입" : "로그인";
915 var loginPath = $"{routePrefix}/login";
916 var signupPath = $"{routePrefix}/signup";
917 var action = isSignup ? signupPath : loginPath;
918 var switchHref = isSignup
919 ? $"{loginPath}?returnUrl={Url(returnUrl)}"
920 : $"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}";
921 var switchText = isSignup ? "이미 계정이 있으신가요? 로그인" : "계정이 없으신가요? 회원가입";
922
923 var builder = new StringBuilder();
924 builder.Append($$"""
925 <!DOCTYPE html>
926 <html lang="ko">
927 <head>
928 <meta charset="utf-8" />
929 <meta name="viewport" content="width=device-width, initial-scale=1" />
930 <title>{{Html(title)}} | Dreamine Identity</title>
931 <style>
932 :root { color-scheme: dark; }
933 * { box-sizing: border-box; }
934 body {
935 margin: 0;
936 min-height: 100vh;
937 display: grid;
938 place-items: center;
939 font-family: "Segoe UI", Arial, sans-serif;
940 background: #0f172a;
941 color: #e5e7eb;
942 }
943 main {
944 width: min(420px, calc(100vw - 32px));
945 padding: 28px;
946 border: 1px solid rgba(148, 163, 184, .28);
947 border-radius: 8px;
948 background: #111827;
949 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
950 }
951 h1 { margin: 0 0 8px; font-size: 28px; letter-spacing: 0; }
952 p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
953 .form-hint { margin: 10px 0 0; color: #93c5fd; font-size: 13px; }
954 label { display: block; margin: 14px 0 6px; color: #cbd5e1; font-size: 14px; }
955 input {
956 width: 100%;
957 height: 44px;
958 padding: 0 12px;
959 border: 1px solid #334155;
960 border-radius: 6px;
961 background: #0b1220;
962 color: #f8fafc;
963 font-size: 15px;
964 }
965 input[type="checkbox"] {
966 width: 18px;
967 height: 18px;
968 flex: 0 0 auto;
969 padding: 0;
970 accent-color: #93c5fd;
971 }
972 .consents {
973 display: grid;
974 gap: 10px;
975 margin-top: 16px;
976 padding: 12px;
977 border: 1px solid #334155;
978 border-radius: 6px;
979 background: #0b1220;
980 }
981 .consent-row {
982 display: flex;
983 align-items: flex-start;
984 gap: 10px;
985 margin: 0;
986 line-height: 1.45;
987 }
988 .consent-row a { color: #93c5fd; }
989 button, .social, .switch {
990 display: flex;
991 width: 100%;
992 height: 44px;
993 align-items: center;
994 justify-content: center;
995 gap: 8px;
996 border-radius: 6px;
997 text-decoration: none;
998 font-weight: 700;
999 }
1000 button {
1001 margin-top: 18px;
1002 border: 0;
1003 background: #e5e7eb;
1004 color: #0f172a;
1005 cursor: pointer;
1006 }
1007 .divider { display: flex; align-items: center; gap: 12px; margin: 22px 0; color: #64748b; }
1008 .divider::before, .divider::after { content: ""; height: 1px; flex: 1; background: #334155; }
1009 .socials { display: grid; gap: 10px; }
1010 .social.google { border: 1px solid #ef4444; color: #f8fafc; }
1011 .social.naver { border: 1px solid #10b981; color: #f8fafc; }
1012 .social.kakao { border: 1px solid #facc15; color: #fde047; }
1013 .browser-warning {
1014 display: none;
1015 margin: 0 0 12px;
1016 padding: 12px;
1017 border: 1px solid rgba(250, 204, 21, .38);
1018 border-radius: 8px;
1019 background: rgba(250, 204, 21, .10);
1020 color: #fde68a;
1021 font-size: 13px;
1022 line-height: 1.55;
1023 }
1024 .browser-warning strong { color: #fef3c7; }
1025 body.embedded-browser .browser-warning { display: block; }
1026 .external-browser-actions {
1027 display: none;
1028 grid-template-columns: 1fr 1fr;
1029 gap: 8px;
1030 margin-top: 10px;
1031 }
1032 body.embedded-browser .external-browser-actions { display: grid; }
1033 .external-open {
1034 display: flex;
1035 min-height: 38px;
1036 align-items: center;
1037 justify-content: center;
1038 padding: 8px 10px;
1039 border: 1px solid rgba(147, 197, 253, .35);
1040 border-radius: 6px;
1041 color: #bfdbfe;
1042 font-size: 13px;
1043 font-weight: 700;
1044 text-decoration: none;
1045 }
1046 body.embedded-browser .social.google {
1047 opacity: .55;
1048 border-color: rgba(239, 68, 68, .35);
1049 }
1050 .switch { margin-top: 16px; color: #93c5fd; font-weight: 600; }
1051 .message, .error {
1052 padding: 10px 12px;
1053 border-radius: 6px;
1054 margin: 14px 0;
1055 line-height: 1.45;
1056 }
1057 .message { background: rgba(14, 165, 233, .12); color: #bae6fd; }
1058 .error { background: rgba(239, 68, 68, .14); color: #fecaca; }
1059 </style>
1060 </head>
1061 <body>
1062 <main>
1063 <h1>{{Html(title)}}</h1>
1064 <p>하나의 계정으로 CodeMaru 및 Dreamine 계열 서비스를 이용합니다.</p>
1065 """);
1066
1067 if (!string.IsNullOrWhiteSpace(message))
1068 {
1069 builder.Append($"""<div class="message">{Html(message)}</div>""");
1070 }
1071
1072 if (!string.IsNullOrWhiteSpace(error))
1073 {
1074 builder.Append($"""<div class="error">{Html(error)}</div>""");
1075 }
1076
1077 builder.Append($$"""
1078 <form method="post" action="{{action}}">
1079 <input type="hidden" name="returnUrl" value="{{Html(returnUrl)}}" />
1080 """);
1081
1082 if (isSignup)
1083 {
1084 builder.Append("""
1085 <label for="displayName">이름</label>
1086 <input id="displayName" name="displayName" autocomplete="name" />
1087 """);
1088 }
1089
1090 builder.Append("""
1091 <label for="email">이메일</label>
1092 <input id="email" name="email" type="email" autocomplete="email" required />
1093 <label for="password">비밀번호</label>
1094 <input id="password" name="password" type="password" autocomplete="current-password" required minlength="8" />
1095 <p class="form-hint">Google, Naver, Kakao로 가입한 계정은 아래 소셜 로그인 버튼을 사용하세요.</p>
1096 """);
1097
1098 if (isSignup)
1099 {
1100 builder.Append($$"""
1101 <label for="confirmPassword">비밀번호 확인</label>
1102 <input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required minlength="8" />
1103 <div class="consents">
1104 <label class="consent-row">
1105 <input type="checkbox" name="termsAccepted" value="true" required />
1106 <span><a href="/terms" target="_blank" rel="noopener">이용약관</a>에 동의합니다.</span>
1107 </label>
1108 <label class="consent-row">
1109 <input type="checkbox" name="privacyAccepted" value="true" required />
1110 <span><a href="/privacy" target="_blank" rel="noopener">개인정보처리방침</a>을 확인했고 개인정보 수집·이용에 동의합니다.</span>
1111 </label>
1112 <label class="consent-row">
1113 <input type="checkbox" name="ageConfirmed" value="true" required />
1114 <span>만 14세 이상입니다. 만 14세 미만은 법정대리인 동의 절차 확인 전 온라인 가입을 진행할 수 없습니다.</span>
1115 </label>
1116 </div>
1117 """);
1118 }
1119
1120 builder.Append($$"""
1121 <button type="submit">{{Html(title)}}</button>
1122 </form>
1123 <a class="switch" href="{{switchHref}}">{{Html(switchText)}}</a>
1124 <div class="divider">또는</div>
1125 <div class="browser-warning">
1126 <strong>Google 로그인 안내</strong><br />
1127 지금 화면이 앱 내부 브라우저라면 Google 로그인이 차단될 수 있습니다.
1128 오른쪽 위 메뉴에서 <strong>브라우저로 열기</strong>를 선택한 뒤 Google 로그인을 다시 시도해 주세요.
1129 <div class="external-browser-actions">
1130 <a class="external-open" id="openChrome" href="#">Chrome으로 열기</a>
1131 <a class="external-open" id="openSamsung" href="#">삼성인터넷으로 열기</a>
1132 </div>
1133 </div>
1134 <div class="socials">
1135 <a class="social google" id="googleLoginLink" href="/signin/google?returnUrl={{Url(returnUrl)}}">G Google</a>
1136 <a class="social naver" href="/signin/naver?returnUrl={{Url(returnUrl)}}">N Naver</a>
1137 <a class="social kakao" href="/signin/kakao?returnUrl={{Url(returnUrl)}}">Kakao</a>
1138 </div>
1139 </main>
1140 <script>
1141 (function () {
1142 var ua = navigator.userAgent || "";
1143 var isEmbedded =
1144 /KAKAOTALK|NAVER\‍(inapp|FBAN|FBAV|Instagram|Line\//i.test(ua) ||
1145 (/\bwv\b/i.test(ua) && /Android/i.test(ua));
1146 if (!isEmbedded) return;
1147
1148 document.body.classList.add("embedded-browser");
1149 var current = window.location.href;
1150 var withoutScheme = current.replace(/^https?:\/\//i, "");
1151 var chrome = document.getElementById("openChrome");
1152 var samsung = document.getElementById("openSamsung");
1153 if (chrome) {
1154 chrome.href = "intent://" + withoutScheme + "#Intent;scheme=https;package=com.android.chrome;end";
1155 }
1156 if (samsung) {
1157 samsung.href = "intent://" + withoutScheme + "#Intent;scheme=https;package=com.sec.android.app.sbrowser;end";
1158 }
1159
1160 var google = document.getElementById("googleLoginLink");
1161 if (!google) return;
1162
1163 google.addEventListener("click", function (event) {
1164 event.preventDefault();
1165 alert("Google 로그인은 현재 앱 내부 브라우저에서 차단될 수 있습니다. 오른쪽 위 메뉴에서 '브라우저로 열기'를 선택한 뒤 다시 시도해 주세요.");
1166 });
1167 })();
1168 </script>
1169 </body>
1170 </html>
1171 """);
1172
1173 return builder.ToString();
1174 }
1175
1216 private static string BuildConsentHtml(string returnUrl, string? error, string routePrefix)
1217 {
1218 var consentPath = $"{routePrefix}/consent";
1219 var builder = new StringBuilder();
1220 builder.Append($$"""
1221 <!DOCTYPE html>
1222 <html lang="ko">
1223 <head>
1224 <meta charset="utf-8" />
1225 <meta name="viewport" content="width=device-width, initial-scale=1" />
1226 <title>필수 동의 | Dreamine Identity</title>
1227 <style>
1228 :root { color-scheme: dark; }
1229 * { box-sizing: border-box; }
1230 body {
1231 margin: 0;
1232 min-height: 100vh;
1233 display: grid;
1234 place-items: center;
1235 font-family: "Segoe UI", Arial, sans-serif;
1236 background: #0f172a;
1237 color: #e5e7eb;
1238 }
1239 main {
1240 width: min(480px, calc(100vw - 32px));
1241 padding: 28px;
1242 border: 1px solid rgba(148, 163, 184, .28);
1243 border-radius: 8px;
1244 background: #111827;
1245 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
1246 }
1247 h1 { margin: 0 0 8px; font-size: 28px; letter-spacing: 0; }
1248 p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
1249 .error {
1250 padding: 10px 12px;
1251 border-radius: 6px;
1252 margin: 14px 0;
1253 background: rgba(239, 68, 68, .14);
1254 color: #fecaca;
1255 line-height: 1.45;
1256 }
1257 .consents {
1258 display: grid;
1259 gap: 12px;
1260 margin-top: 16px;
1261 padding: 14px;
1262 border: 1px solid #334155;
1263 border-radius: 6px;
1264 background: #0b1220;
1265 }
1266 label {
1267 display: flex;
1268 align-items: flex-start;
1269 gap: 10px;
1270 line-height: 1.45;
1271 color: #dbeafe;
1272 }
1273 input[type="checkbox"] {
1274 width: 18px;
1275 height: 18px;
1276 flex: 0 0 auto;
1277 accent-color: #93c5fd;
1278 }
1279 a { color: #93c5fd; }
1280 button {
1281 display: flex;
1282 width: 100%;
1283 height: 44px;
1284 align-items: center;
1285 justify-content: center;
1286 margin-top: 18px;
1287 border: 0;
1288 border-radius: 6px;
1289 background: #e5e7eb;
1290 color: #0f172a;
1291 cursor: pointer;
1292 font-weight: 700;
1293 }
1294 </style>
1295 </head>
1296 <body>
1297 <main>
1298 <h1>필수 동의</h1>
1299 <p>CodeMaru 및 Dreamine 계열 서비스를 계속 이용하려면 아래 항목을 확인해 주세요.</p>
1300 """);
1301
1302 if (!string.IsNullOrWhiteSpace(error))
1303 {
1304 builder.Append($"""<div class="error">{Html(error)}</div>""");
1305 }
1306
1307 builder.Append($$"""
1308 <form method="post" action="{{consentPath}}">
1309 <input type="hidden" name="returnUrl" value="{{Html(returnUrl)}}" />
1310 <div class="consents">
1311 <label>
1312 <input type="checkbox" name="termsAccepted" value="true" required />
1313 <span><a href="/terms" target="_blank" rel="noopener">이용약관</a>에 동의합니다.</span>
1314 </label>
1315 <label>
1316 <input type="checkbox" name="privacyAccepted" value="true" required />
1317 <span><a href="/privacy" target="_blank" rel="noopener">개인정보처리방침</a>을 확인했고 개인정보 수집·이용에 동의합니다.</span>
1318 </label>
1319 <label>
1320 <input type="checkbox" name="ageConfirmed" value="true" required />
1321 <span>만 14세 이상입니다. 만 14세 미만은 법정대리인 동의 절차 확인 전 온라인 가입을 진행할 수 없습니다.</span>
1322 </label>
1323 </div>
1324 <button type="submit">동의하고 계속</button>
1325 </form>
1326 </main>
1327 </body>
1328 </html>
1329 """);
1330
1331 return builder.ToString();
1332 }
1333
1390 private static string BuildAccountHtml(
1391 AuthUser user,
1392 string returnUrl,
1393 string? message,
1394 string? error,
1395 string routePrefix = "")
1396 {
1397 var accountPath = $"{routePrefix}/account";
1398 var signoutPath = $"{routePrefix}/signout";
1399 var provider = string.IsNullOrWhiteSpace(user.Provider) ? "Unknown" : user.Provider;
1400 var email = string.IsNullOrWhiteSpace(user.Email) ? "제공되지 않음" : user.Email;
1401 var isLocal = string.Equals(user.Provider, LocalProvider, StringComparison.OrdinalIgnoreCase);
1402 var avatar = string.IsNullOrWhiteSpace(user.AvatarUrl)
1403 ? "<div class=\"avatar-fallback\">U</div>"
1404 : $"""<img class="avatar" src="{Html(user.AvatarUrl)}" alt="" />""";
1405 var passwordSection = isLocal
1406 ? $$"""
1407 <section>
1408 <h2>비밀번호 변경</h2>
1409 <form method="post" action="{{accountPath}}">
1410 <input type="hidden" name="returnUrl" value="{{Html(returnUrl)}}" />
1411 <input type="hidden" name="accountAction" value="password" />
1412 <label for="currentPassword">현재 비밀번호</label>
1413 <input id="currentPassword" name="currentPassword" type="password" autocomplete="current-password" required />
1414 <label for="newPassword">새 비밀번호</label>
1415 <input id="newPassword" name="newPassword" type="password" autocomplete="new-password" required minlength="8" />
1416 <label for="confirmPassword">새 비밀번호 확인</label>
1417 <input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required minlength="8" />
1418 <button type="submit">비밀번호 변경</button>
1419 </form>
1420 </section>
1421 """
1422 : $$"""
1423 <section>
1424 <h2>비밀번호</h2>
1425 <p class="note">{{Html(provider)}} 로그인 계정은 CodeMaru에 별도 비밀번호가 없습니다. 비밀번호 변경은 해당 로그인 제공자에서 진행해 주세요.</p>
1426 </section>
1427 """;
1428
1429 var builder = new StringBuilder();
1430 builder.Append($$"""
1431 <!DOCTYPE html>
1432 <html lang="ko">
1433 <head>
1434 <meta charset="utf-8" />
1435 <meta name="viewport" content="width=device-width, initial-scale=1" />
1436 <title>내 계정 | Dreamine Identity</title>
1437 <style>
1438 :root { color-scheme: dark; }
1439 * { box-sizing: border-box; }
1440 body {
1441 margin: 0;
1442 min-height: 100vh;
1443 display: grid;
1444 place-items: center;
1445 font-family: "Segoe UI", Arial, sans-serif;
1446 background: #0f172a;
1447 color: #e5e7eb;
1448 }
1449 main {
1450 width: min(480px, calc(100vw - 32px));
1451 padding: 28px;
1452 border: 1px solid rgba(148, 163, 184, .28);
1453 border-radius: 8px;
1454 background: #111827;
1455 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
1456 }
1457 .head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
1458 .avatar, .avatar-fallback {
1459 width: 54px;
1460 height: 54px;
1461 border-radius: 50%;
1462 object-fit: cover;
1463 display: grid;
1464 place-items: center;
1465 background: #1e293b;
1466 color: #cbd5e1;
1467 font-weight: 800;
1468 }
1469 h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
1470 h2 { margin: 0 0 12px; font-size: 18px; letter-spacing: 0; }
1471 p { margin: 6px 0 0; color: #94a3b8; line-height: 1.5; }
1472 section { margin-top: 22px; padding-top: 20px; border-top: 1px solid #263244; }
1473 dl { display: grid; gap: 10px; margin: 20px 0; }
1474 div.row {
1475 display: grid;
1476 grid-template-columns: 110px minmax(0, 1fr);
1477 gap: 12px;
1478 padding: 10px 0;
1479 border-bottom: 1px solid #263244;
1480 }
1481 dt { color: #94a3b8; }
1482 dd { margin: 0; overflow-wrap: anywhere; }
1483 label { display: block; margin: 16px 0 6px; color: #cbd5e1; font-size: 14px; }
1484 input {
1485 width: 100%;
1486 height: 44px;
1487 padding: 0 12px;
1488 border: 1px solid #334155;
1489 border-radius: 6px;
1490 background: #0b1220;
1491 color: #f8fafc;
1492 font-size: 15px;
1493 }
1494 .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 18px; }
1495 button, a.button {
1496 display: flex;
1497 height: 44px;
1498 align-items: center;
1499 justify-content: center;
1500 border-radius: 6px;
1501 text-decoration: none;
1502 font-weight: 700;
1503 }
1504 button { border: 0; background: #e5e7eb; color: #0f172a; cursor: pointer; }
1505 a.button { border: 1px solid #334155; color: #e5e7eb; }
1506 form > button { width: 100%; margin-top: 18px; }
1507 .note {
1508 padding: 12px;
1509 border: 1px solid #334155;
1510 border-radius: 6px;
1511 background: #0b1220;
1512 }
1513 .message, .error {
1514 padding: 10px 12px;
1515 border-radius: 6px;
1516 margin: 14px 0;
1517 line-height: 1.45;
1518 }
1519 .message { background: rgba(14, 165, 233, .12); color: #bae6fd; }
1520 .error { background: rgba(239, 68, 68, .14); color: #fecaca; }
1521 </style>
1522 </head>
1523 <body>
1524 <main>
1525 <div class="head">
1526 {{avatar}}
1527 <div>
1528 <h1>내 계정</h1>
1529 <p>CodeMaru 및 Dreamine 계열 서비스에서 사용할 기본 정보를 관리합니다.</p>
1530 </div>
1531 </div>
1532 """);
1533
1534 if (!string.IsNullOrWhiteSpace(message))
1535 {
1536 builder.Append($"""<div class="message">{Html(message)}</div>""");
1537 }
1538
1539 if (!string.IsNullOrWhiteSpace(error))
1540 {
1541 builder.Append($"""<div class="error">{Html(error)}</div>""");
1542 }
1543
1544 builder.Append($$"""
1545 <dl>
1546 <div class="row"><dt>로그인 방식</dt><dd>{{Html(provider)}}</dd></div>
1547 <div class="row"><dt>이메일</dt><dd>{{Html(email)}}</dd></div>
1548 </dl>
1549 <form method="post" action="{{accountPath}}">
1550 <input type="hidden" name="returnUrl" value="{{Html(returnUrl)}}" />
1551 <input type="hidden" name="accountAction" value="profile" />
1552 <label for="displayName">표시 이름</label>
1553 <input id="displayName" name="displayName" autocomplete="name" required value="{{Html(user.DisplayName)}}" />
1554 <div class="actions">
1555 <button type="submit">저장</button>
1556 <a class="button" href="{{Html(returnUrl)}}">돌아가기</a>
1557 <a class="button" href="{{signoutPath}}?returnUrl={{Url(returnUrl)}}">로그아웃</a>
1558 </div>
1559 </form>
1560 {{passwordSection}}
1561 </main>
1562 </body>
1563 </html>
1564 """);
1565
1566 return builder.ToString();
1567 }
1568
1593 private static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
1594
1619 private static string Url(string? value) => Uri.EscapeDataString(value ?? string.Empty);
1620
1645 private static bool IsEmbeddedMobileBrowser(string userAgent)
1646 {
1647 if (string.IsNullOrWhiteSpace(userAgent))
1648 {
1649 return false;
1650 }
1651
1652 return userAgent.Contains("KAKAOTALK", StringComparison.OrdinalIgnoreCase)
1653 || userAgent.Contains("NAVER(inapp", StringComparison.OrdinalIgnoreCase)
1654 || userAgent.Contains("FBAN", StringComparison.OrdinalIgnoreCase)
1655 || userAgent.Contains("FBAV", StringComparison.OrdinalIgnoreCase)
1656 || userAgent.Contains("Instagram", StringComparison.OrdinalIgnoreCase)
1657 || userAgent.Contains("Line/", StringComparison.OrdinalIgnoreCase)
1658 || (userAgent.Contains("; wv)", StringComparison.OrdinalIgnoreCase)
1659 && userAgent.Contains("Android", StringComparison.OrdinalIgnoreCase));
1660 }
1661}
static string BuildAccountHtml(AuthUser user, string returnUrl, string? message, string? error, string routePrefix="")
static string Url(string? value)
static string BuildConsentHtml(string returnUrl, string? error, string routePrefix)
static bool IsEmbeddedMobileBrowser(string userAgent)
static string Html(string? value)
Task< AuthUser?> GetByIdAsync(long id, CancellationToken cancellationToken=default)
Task< AuthUser > CreateLocalAsync(string email, string displayName, string password, CancellationToken cancellationToken=default)
Task< AuthUser?> ValidateLocalAsync(string email, string password, CancellationToken cancellationToken=default)
Task< AuthUser?> AcceptRequiredConsentsAsync(long id, DateTime acceptedAtUtc, CancellationToken cancellationToken=default)
Task< AuthUser?> UpdateDisplayNameAsync(long id, string displayName, CancellationToken cancellationToken=default)
Task< AuthUser?> ChangeLocalPasswordAsync(long id, string currentPassword, string newPassword, CancellationToken cancellationToken=default)