23internal static class AuthEndpoints
33 private const string LocalProvider =
"Local";
51 public static void MapAuthEndpoints(
this IEndpointRouteBuilder endpoints)
53 endpoints.MapGet(
"/_identity/login", (HttpContext http,
string? returnUrl,
string? mode,
string? message,
string? error) =>
55 BuildLoginHtml(SafeReturnUrl(returnUrl), mode, message, error,
"/_identity"),
56 "text/html; charset=utf-8"));
58 endpoints.MapGet(
"/login", (HttpContext http,
string? returnUrl,
string? mode,
string? message,
string? error) =>
60 BuildLoginHtml(SafeReturnUrl(returnUrl), mode, message, error),
61 "text/html; charset=utf-8"));
63 endpoints.MapPost(
"/_identity/login", async (HttpContext http,
IUserStore userStore) =>
64 await HandleLocalLoginAsync(http, userStore,
"/_identity").ConfigureAwait(
false));
66 endpoints.MapPost(
"/login", async (HttpContext http,
IUserStore userStore) =>
67 await HandleLocalLoginAsync(http, userStore,
string.Empty).ConfigureAwait(
false));
69 endpoints.MapPost(
"/_identity/signup", async (HttpContext http,
IUserStore userStore) =>
70 await HandleSignupAsync(http, userStore,
"/_identity").ConfigureAwait(
false));
72 endpoints.MapPost(
"/signup", async (HttpContext http,
IUserStore userStore) =>
73 await HandleSignupAsync(http, userStore,
string.Empty).ConfigureAwait(
false));
75 endpoints.MapGet(
"/signin/google", (HttpContext http,
string? returnUrl) =>
77 var safeReturnUrl = SafeReturnUrl(returnUrl);
80 return Results.Redirect($
"/_identity/login?returnUrl={Url(safeReturnUrl)}&error={Url("Google 로그인은 카카오톡/네이버앱 같은 앱 내부 브라우저에서 차단됩니다. 오른쪽 위 메뉴에서
'브라우저로 열기'를 선택한 뒤 다시 시도해 주세요. Naver/Kakao 로그인은 현재 화면에서도 사용할 수 있습니다.
")}");
83 return Results.Challenge(
84 new AuthenticationProperties { RedirectUri = safeReturnUrl },
85 new[] { GoogleDefaults.AuthenticationScheme });
88 endpoints.MapGet(
"/signin/naver", (HttpContext http,
string? returnUrl) =>
90 new AuthenticationProperties { RedirectUri = SafeReturnUrl(returnUrl) },
91 new[] { NaverAuthenticationDefaults.AuthenticationScheme }));
93 endpoints.MapGet(
"/signin/kakao", (HttpContext http,
string? returnUrl) =>
95 new AuthenticationProperties { RedirectUri = SafeReturnUrl(returnUrl) },
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));
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));
104 endpoints.MapGet(
"/_identity/consent", async (HttpContext http,
IUserStore userStore,
string? returnUrl,
string? error) =>
105 await HandleConsentPageAsync(http, userStore, returnUrl, error,
"/_identity").ConfigureAwait(
false));
107 endpoints.MapGet(
"/consent", async (HttpContext http,
IUserStore userStore,
string? returnUrl,
string? error) =>
108 await HandleConsentPageAsync(http, userStore, returnUrl, error,
string.Empty).ConfigureAwait(
false));
110 endpoints.MapPost(
"/_identity/account", async (HttpContext http,
IUserStore userStore) =>
111 await HandleAccountPostAsync(http, userStore,
"/_identity").ConfigureAwait(
false));
113 endpoints.MapPost(
"/account", async (HttpContext http,
IUserStore userStore) =>
114 await HandleAccountPostAsync(http, userStore,
string.Empty).ConfigureAwait(
false));
116 endpoints.MapPost(
"/_identity/consent", async (HttpContext http,
IUserStore userStore) =>
117 await HandleConsentPostAsync(http, userStore,
"/_identity").ConfigureAwait(
false));
119 endpoints.MapPost(
"/consent", async (HttpContext http,
IUserStore userStore) =>
120 await HandleConsentPostAsync(http, userStore,
string.Empty).ConfigureAwait(
false));
122 endpoints.MapGet(
"/_identity/signout", async (HttpContext http,
string? returnUrl) =>
124 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
125 return Results.Redirect(SafeReturnUrl(returnUrl));
128 endpoints.MapGet(
"/signout", async (HttpContext http,
string? returnUrl) =>
130 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
131 return Results.Redirect(SafeReturnUrl(returnUrl));
175 private static async Task<IResult> HandleLocalLoginAsync(HttpContext http,
IUserStore userStore,
string routePrefix)
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();
184 .ConfigureAwait(
false);
187 return Results.Redirect($
"{loginPath}?returnUrl={Url(returnUrl)}&error={Url("직접 회원가입한 이메일/비밀번호가 올바르지 않습니다. Google, Naver, Kakao 계정은 아래 소셜 로그인 버튼을 사용해 주세요.
")}");
190 await SignInAsync(http, user).ConfigureAwait(
false);
191 return Results.Redirect(returnUrl);
234 private static async Task<IResult> HandleSignupAsync(HttpContext http,
IUserStore userStore,
string routePrefix)
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();
244 if (!HasAcceptedRequiredSignupConsents(form))
246 return Results.Redirect($
"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url("필수 약관, 개인정보처리방침, 만 14세 이상 확인에 모두 동의해야 가입할 수 있습니다.
")}");
249 if (!
string.Equals(password, confirmPassword, StringComparison.Ordinal))
251 return Results.Redirect($
"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url("비밀번호 확인이 일치하지 않습니다.
")}");
256 var user = await userStore.
CreateLocalAsync(email, displayName, password, http.RequestAborted)
257 .ConfigureAwait(
false);
259 .ConfigureAwait(
false) ?? user;
260 await SignInAsync(http, user).ConfigureAwait(
false);
261 return Results.Redirect(returnUrl);
263 catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
265 return Results.Redirect($
"{loginPath}?mode=signup&returnUrl={Url(returnUrl)}&error={Url(ex.Message)}");
333 private static async Task<IResult> HandleAccountPageAsync(
341 var loginPath = $
"{routePrefix}/login";
342 var accountPath = $
"{routePrefix}/account";
343 var userId = GetCurrentUserId(http);
346 return Results.Redirect($
"{loginPath}?returnUrl={Url(accountPath)}");
349 var user = await userStore.
GetByIdAsync(userId.Value, http.RequestAborted).ConfigureAwait(
false);
352 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(
false);
353 return Results.Redirect($
"{loginPath}?returnUrl={Url(accountPath)}");
356 return Results.Content(
357 BuildAccountHtml(user, SafeReturnUrl(returnUrl), message, error, routePrefix),
358 "text/html; charset=utf-8");
401 private static async Task<IResult> HandleAccountPostAsync(HttpContext http,
IUserStore userStore,
string routePrefix)
403 var loginPath = $
"{routePrefix}/login";
404 var accountPath = $
"{routePrefix}/account";
405 var userId = GetCurrentUserId(http);
408 return Results.Redirect($
"{loginPath}?returnUrl={Url(accountPath)}");
411 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(
false);
412 var returnUrl = SafeReturnUrl(form[
"returnUrl"]);
413 var accountAction = form[
"accountAction"].ToString();
419 if (
string.Equals(accountAction,
"password", StringComparison.OrdinalIgnoreCase))
421 var currentPassword = form[
"currentPassword"].ToString();
422 var newPassword = form[
"newPassword"].ToString();
423 var confirmPassword = form[
"confirmPassword"].ToString();
425 if (!
string.Equals(newPassword, confirmPassword, StringComparison.Ordinal))
427 return Results.Redirect($
"{accountPath}?returnUrl={Url(returnUrl)}&error={Url("새 비밀번호 확인이 일치하지 않습니다.
")}");
435 .ConfigureAwait(
false);
436 message =
"비밀번호가 변경되었습니다.";
440 var displayName = form[
"displayName"].ToString();
442 .ConfigureAwait(
false);
443 message =
"계정 정보가 저장되었습니다.";
448 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(
false);
449 return Results.Redirect($
"{loginPath}?returnUrl={Url(accountPath)}");
452 await SignInAsync(http, user).ConfigureAwait(
false);
453 return Results.Redirect($
"{accountPath}?returnUrl={Url(returnUrl)}&message={Url(message)}");
455 catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
457 return Results.Redirect($
"{accountPath}?returnUrl={Url(returnUrl)}&error={Url(ex.Message)}");
517 private static async Task<IResult> HandleConsentPageAsync(
524 var loginPath = $
"{routePrefix}/login";
525 var userId = GetCurrentUserId(http);
528 return Results.Redirect($
"{loginPath}?returnUrl={Url($"{routePrefix}/consent
")}");
531 var user = await userStore.
GetByIdAsync(userId.Value, http.RequestAborted).ConfigureAwait(
false);
534 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(
false);
535 return Results.Redirect($
"{loginPath}?returnUrl={Url($"{routePrefix}/consent
")}");
538 var safeReturnUrl = SafeReturnUrl(returnUrl);
539 if (RequiredConsentPolicy.HasRequiredConsents(user))
541 return Results.Redirect(safeReturnUrl);
544 return Results.Content(
546 "text/html; charset=utf-8");
589 private static async Task<IResult> HandleConsentPostAsync(HttpContext http,
IUserStore userStore,
string routePrefix)
591 var loginPath = $
"{routePrefix}/login";
592 var consentPath = $
"{routePrefix}/consent";
593 var userId = GetCurrentUserId(http);
596 return Results.Redirect($
"{loginPath}?returnUrl={Url(consentPath)}");
599 var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(
false);
600 var returnUrl = SafeReturnUrl(form[
"returnUrl"]);
601 if (!HasAcceptedRequiredSignupConsents(form))
603 return Results.Redirect($
"{consentPath}?returnUrl={Url(returnUrl)}&error={Url("필수 항목에 모두 동의해야 서비스를 계속 이용할 수 있습니다.
")}");
607 .ConfigureAwait(
false);
610 await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(
false);
611 return Results.Redirect($
"{loginPath}?returnUrl={Url(consentPath)}");
614 await SignInAsync(http, user).ConfigureAwait(
false);
615 return Results.Redirect(returnUrl);
642 private static string SafeReturnUrl(
string? returnUrl)
644 if (
string.IsNullOrWhiteSpace(returnUrl))
652 if (returnUrl.StartsWith(
"/", StringComparison.Ordinal)
653 && !returnUrl.StartsWith(
"//", StringComparison.Ordinal))
658 if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var uri)
659 || uri.Scheme is not (
"https" or
"http"))
664 return IsAllowedReturnHost(uri.Host) ? uri.ToString() :
"/";
691 private static bool IsAllowedReturnHost(
string host)
693 if (
string.IsNullOrWhiteSpace(host))
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);
737 private static async Task SignInAsync(HttpContext http,
AuthUser user)
739 var claims =
new List<Claim>
742 new(ClaimTypes.Email, user.
Email),
748 var identity =
new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
749 var principal =
new ClaimsPrincipal(identity);
750 await http.SignInAsync(
751 CookieAuthenticationDefaults.AuthenticationScheme,
753 new AuthenticationProperties { IsPersistent = true }).ConfigureAwait(
false);
780 private static long? GetCurrentUserId(HttpContext http)
783 return long.TryParse(
785 System.Globalization.NumberStyles.None,
786 System.Globalization.CultureInfo.InvariantCulture,
816 private static bool HasAcceptedRequiredSignupConsents(IFormCollection form) =>
817 IsChecked(form[
"termsAccepted"]) &&
818 IsChecked(form[
"privacyAccepted"]) &&
819 IsChecked(form[
"ageConfirmed"]);
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);
906 private static string BuildLoginHtml(
911 string routePrefix =
"")
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 ?
"이미 계정이 있으신가요? 로그인" :
"계정이 없으신가요? 회원가입";
923 var builder =
new StringBuilder();
928 <meta charset="utf-8
" />
929 <meta name="viewport
" content="width=device-width, initial-scale=1
" />
930 <title>{{Html(title)}} | Dreamine Identity</title>
932 :root { color-scheme: dark; }
933 * { box-sizing: border-box; }
939 font-family: "Segoe UI
", Arial, sans-serif;
944 width: min(420px, calc(100vw - 32px));
946 border: 1px solid rgba(148, 163, 184, .28);
949 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
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; }
959 border: 1px solid #334155;
965 input[type="checkbox
"] {
970 accent-color: #93c5fd;
977 border: 1px solid #334155;
983 align-items: flex-start;
988 .consent-row a { color: #93c5fd; }
989 button, .social, .switch {
994 justify-content: center;
997 text-decoration: none;
1003 background: #e5e7eb;
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; }
1017 border: 1px solid rgba(250, 204, 21, .38);
1019 background: rgba(250, 204, 21, .10);
1024 .browser-warning strong { color: #fef3c7; }
1025 body.embedded-browser .browser-warning { display: block; }
1026 .external-browser-actions {
1028 grid-template-columns: 1fr 1fr;
1032 body.embedded-browser .external-browser-actions { display: grid; }
1036 align-items: center;
1037 justify-content: center;
1039 border: 1px solid rgba(147, 197, 253, .35);
1044 text-decoration: none;
1046 body.embedded-browser .social.google {
1048 border-color: rgba(239, 68, 68, .35);
1050 .switch { margin-top: 16px; color: #93c5fd; font-weight: 600; }
1057 .message { background: rgba(14, 165, 233, .12); color: #bae6fd; }
1058 .error { background: rgba(239, 68, 68, .14); color: #fecaca; }
1063 <h1>{{Html(title)}}</h1>
1064 <p>하나의 계정으로 CodeMaru 및 Dreamine 계열 서비스를 이용합니다.</p>
1067 if (!
string.IsNullOrWhiteSpace(message))
1069 builder.Append($
"""<div class="message
">{Html(message)}</div>""");
1072 if (!
string.IsNullOrWhiteSpace(error))
1074 builder.Append($
"""<div class="error
">{Html(error)}</div>""");
1077 builder.Append($$
"""
1078 <form method="post
" action="{{action}}
">
1079 <input type="hidden
" name="returnUrl
" value="{{
Html(returnUrl)}}
" />
1085 <label for="displayName
">이름</label>
1086 <input id="displayName
" name="displayName
" autocomplete="name
" />
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>
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>
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>
1112 <label class="consent-row
">
1113 <input type="checkbox
" name="ageConfirmed
" value="true" required />
1114 <span>만 14세 이상입니다. 만 14세 미만은 법정대리인 동의 절차 확인 전 온라인 가입을 진행할 수 없습니다.</span>
1120 builder.Append($$
"""
1121 <button type="submit
">{{Html(title)}}</button>
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>
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>
1142 var ua = navigator.userAgent || "";
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;
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
");
1154 chrome.href = "intent:
1157 samsung.href =
"intent://" + withoutScheme +
"#Intent;scheme=https;package=com.sec.android.app.sbrowser;end";
1160 var google = document.getElementById(
"googleLoginLink");
1161 if (!google)
return;
1163 google.addEventListener(
"click",
function (event) {
1164 event.preventDefault();
1165 alert(
"Google 로그인은 현재 앱 내부 브라우저에서 차단될 수 있습니다. 오른쪽 위 메뉴에서 '브라우저로 열기'를 선택한 뒤 다시 시도해 주세요.");
1173 return builder.ToString();
1216 private static string BuildConsentHtml(string returnUrl, string? error, string routePrefix)
1218 var consentPath = $"{routePrefix}/consent
";
1219 var builder = new StringBuilder();
1220 builder.Append($$"""
1224 <meta charset=
"utf-8" />
1225 <meta name=
"viewport" content=
"width=device-width, initial-scale=1" />
1228 :root { color-scheme: dark; }
1229 * { box-sizing: border-box; }
1234 place-items: center;
1235 font-family:
"Segoe UI", Arial, sans-serif;
1236 background: #0f172a;
1240 width: min(480px, calc(100vw - 32px));
1242 border: 1px solid rgba(148, 163, 184, .28);
1244 background: #111827;
1245 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
1247 h1 { margin: 0 0 8px; font-size: 28px; letter-spacing: 0; }
1248 p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
1253 background: rgba(239, 68, 68, .14);
1262 border: 1px solid #334155;
1264 background: #0b1220;
1268 align-items: flex-start;
1273 input[type=
"checkbox"] {
1277 accent-color: #93c5fd;
1279 a { color: #93c5fd; }
1284 align-items: center;
1285 justify-content: center;
1289 background: #e5e7eb;
1299 <p>CodeMaru 및 Dreamine 계열 서비스를 계속 이용하려면 아래 항목을 확인해 주세요.</p>
1302 if (!string.IsNullOrWhiteSpace(error))
1304 builder.Append($"""<div
class=
"error">{
Html(error)}</div>
""");
1307 builder.Append($$"""
1308 <form method=
"post" action=
"{{consentPath}}">
1309 <input type=
"hidden" name=
"returnUrl" value=
"{{Html(returnUrl)}}" />
1310 <div
class=
"consents">
1312 <input type=
"checkbox" name=
"termsAccepted" value=
"true" required />
1313 <span><a href=
"/terms" target=
"_blank" rel=
"noopener">이용약관</a>에 동의합니다.</span>
1316 <input type=
"checkbox" name=
"privacyAccepted" value=
"true" required />
1317 <span><a href=
"/privacy" target=
"_blank" rel=
"noopener">개인정보처리방침</a>을 확인했고 개인정보 수집·이용에 동의합니다.</span>
1320 <input type=
"checkbox" name=
"ageConfirmed" value=
"true" required />
1321 <span>만 14세 이상입니다. 만 14세 미만은 법정대리인 동의 절차 확인 전 온라인 가입을 진행할 수 없습니다.</span>
1324 <button type=
"submit">동의하고 계속</button>
1331 return builder.ToString();
1390 private static string BuildAccountHtml(
1395 string routePrefix = "
")
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
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>
1425 <p class="note
">{{Html(provider)}} 로그인 계정은 CodeMaru에 별도 비밀번호가 없습니다. 비밀번호 변경은 해당 로그인 제공자에서 진행해 주세요.</p>
1429 var builder =
new StringBuilder();
1430 builder.Append($$
"""
1434 <meta charset="utf-8
" />
1435 <meta name="viewport
" content="width=device-width, initial-scale=1
" />
1436 <title>내 계정 | Dreamine Identity</title>
1438 :root { color-scheme: dark; }
1439 * { box-sizing: border-box; }
1444 place-items: center;
1445 font-family: "Segoe UI
", Arial, sans-serif;
1446 background: #0f172a;
1450 width: min(480px, calc(100vw - 32px));
1452 border: 1px solid rgba(148, 163, 184, .28);
1454 background: #111827;
1455 box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
1457 .head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
1458 .avatar, .avatar-fallback {
1464 place-items: center;
1465 background: #1e293b;
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; }
1476 grid-template-columns: 110px minmax(0, 1fr);
1479 border-bottom: 1px solid #263244;
1481 dt { color: #94a3b8; }
1482 dd { margin: 0; overflow-wrap: anywhere; }
1483 label { display: block; margin: 16px 0 6px; color: #cbd5e1; font-size: 14px; }
1488 border: 1px solid #334155;
1490 background: #0b1220;
1494 .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 18px; }
1498 align-items: center;
1499 justify-content: center;
1501 text-decoration: none;
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; }
1509 border: 1px solid #334155;
1511 background: #0b1220;
1519 .message { background: rgba(14, 165, 233, .12); color: #bae6fd; }
1520 .error { background: rgba(239, 68, 68, .14); color: #fecaca; }
1529 <p>CodeMaru 및 Dreamine 계열 서비스에서 사용할 기본 정보를 관리합니다.</p>
1534 if (!
string.IsNullOrWhiteSpace(message))
1536 builder.Append($
"""<div class="message
">{Html(message)}</div>""");
1539 if (!
string.IsNullOrWhiteSpace(error))
1541 builder.Append($
"""<div class="error
">{Html(error)}</div>""");
1544 builder.Append($$
"""
1546 <div class="row
"><dt>로그인 방식</dt><dd>{{Html(provider)}}</dd></div>
1547 <div class="row
"><dt>이메일</dt><dd>{{Html(email)}}</dd></div>
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>
1566 return builder.ToString();
1593 private static string Html(
string? value) => WebUtility.HtmlEncode(value ??
string.Empty);
1619 private static string Url(
string? value) => Uri.EscapeDataString(value ??
string.Empty);
1647 if (
string.IsNullOrWhiteSpace(userAgent))
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));