diff --git a/BaronSoftware.SSO.Sample/MainWindow.xaml.cs b/BaronSoftware.SSO.Sample/MainWindow.xaml.cs index 7f46294..49a7533 100644 --- a/BaronSoftware.SSO.Sample/MainWindow.xaml.cs +++ b/BaronSoftware.SSO.Sample/MainWindow.xaml.cs @@ -3,6 +3,8 @@ using BaronSoftware.Auth; using BaronSoftware.Auth.Sample; using System; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using System.Windows; @@ -113,6 +115,7 @@ namespace BaronSoftware.SSO.Sample sb.AppendLine($"UserId(sub) : {u.UUID}"); sb.AppendLine($"Name : {u.Name}"); sb.AppendLine($"Email : {u.Email}"); + sb.AppendLine($"TenantIds : {string.Join(", ", u.AllTenantIds ?? Array.Empty())}"); sb.AppendLine($"Last Auth Time : {u.LastAuthTime}"); sb.AppendLine($"Claims Start------------ \n "); sb.AppendLine(string.Join("\n", u.Claims.Select(kv => $" {kv.Key}: {kv.Value}"))); @@ -123,15 +126,30 @@ namespace BaronSoftware.SSO.Sample sb.AppendLine(); sb.AppendLine("==== token 엔드포인트 응답 (원본) ===="); - sb.AppendLine(u.RawTokenResponse); + sb.AppendLine(PrettyJson(u.RawTokenResponse)); sb.AppendLine(); sb.AppendLine("==== userinfo 응답 ===="); - sb.AppendLine(u.Raw); + sb.AppendLine(PrettyJson(u.Raw)); return sb.ToString(); } + private static readonly JsonSerializerOptions PrettyJsonOptions = new() + { + WriteIndented = true, + // 한글 등 비ASCII를 \uXXXX로 이스케이프하지 않고 그대로 표시 + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// JSON 문자열을 들여쓰기(pretty) 형태로 변환. 파싱 실패 시 원본 반환. + private static string PrettyJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) return json; + try { return JsonNode.Parse(json)?.ToJsonString(PrettyJsonOptions) ?? json; } + catch { return json; } + } + private async void TokenLoginBuggon_Click(object sender, RoutedEventArgs e) => await RunAsync("토큰 로그인", () => _license.SignInAsync(_license.CurrentUser.RefreshToken)); } } diff --git a/BaronSoftware.SSO.Sample/SampleSettings.cs b/BaronSoftware.SSO.Sample/SampleSettings.cs index 694816d..4a44d96 100644 --- a/BaronSoftware.SSO.Sample/SampleSettings.cs +++ b/BaronSoftware.SSO.Sample/SampleSettings.cs @@ -1,6 +1,7 @@ using BaronSoftware; using BaronSoftware.Auth; using BaronSoftware.SSO; +using BaronSoftware.SSO.Sample; using System; using System.IO; using System.Text.Json; @@ -72,6 +73,7 @@ namespace BaronSoftware.Auth.Sample Authority = Oidc.Authority, ClientId = Oidc.ClientId, RedirectUri = Oidc.RedirectUri, + Validator = new SimpleUserValidator() }; } } diff --git a/BaronSoftware.SSO.Sample/SimpleUserValidator.cs b/BaronSoftware.SSO.Sample/SimpleUserValidator.cs new file mode 100644 index 0000000..4cd3153 --- /dev/null +++ b/BaronSoftware.SSO.Sample/SimpleUserValidator.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BaronSoftware.SSO.Sample +{ + internal class SimpleUserValidator : IUserValidator + { + public bool Validate(UserInfo user) + { + // 가족사 아니면 거절 + return false; + } + } +} diff --git a/BaronSoftware.SSO/BaronSSO.cs b/BaronSoftware.SSO/BaronSSO.cs index 4dc951b..f0805be 100644 --- a/BaronSoftware.SSO/BaronSSO.cs +++ b/BaronSoftware.SSO/BaronSSO.cs @@ -1,9 +1,4 @@ using BaronSoftware.SSO.Exceptions; -using System.IO; -using System.Net.Http; -using System.Net.Sockets; -using System.Text.Json.Nodes; -using System.Threading.Tasks; namespace BaronSoftware.SSO { @@ -65,7 +60,14 @@ namespace BaronSoftware.SSO throw new InvalidOperationException($"Failed to get userinfo : {token.ToString()}"); user = UserInfo.FromJson(userJson, token); - option?.Validator?.Validate(user); + + if (user == null || option?.Validator == null) + throw new NullReferenceException("user or validator option is null"); + + // 가족사가 아니고, 인증도 실패 시 + if(!user.IsFamily()&& !option.Validator.Validate(user)) + throw new InvalidUserException("Failed to authorize user"); + CurrentUser = user; CurrentUser.Save(); } @@ -76,17 +78,11 @@ namespace BaronSoftware.SSO /// public async Task SignOutAsync() { - // 1) 로그아웃 전에 id_token을 확보(서버 세션 종료용 id_token_hint). Clear 이전에 추출해야 한다. - var idToken = ExtractIdToken(CurrentUser) ?? ExtractIdToken(UserInfo.FromSsoFile()); - - // 2) 로컬 사용자 정보 삭제 - UserInfo.Clear(); - CurrentUser = null; - - // 3) SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리 try { - await client.LogoutAsync(idToken); + //SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리 + UserInfo.Clear(); + await client.LogoutAsync(CurrentUser?.IdToken); } catch { @@ -94,21 +90,6 @@ namespace BaronSoftware.SSO } } - /// UserInfo의 원본 토큰 응답에서 id_token을 추출한다(로그아웃 id_token_hint용). - private static string ExtractIdToken(UserInfo user) - { - if (string.IsNullOrEmpty(user?.RawTokenResponse)) - return null; - try - { - return JsonNode.Parse(user.RawTokenResponse)?["id_token"]?.GetValue(); - } - catch - { - return null; - } - } - private async Task CheckConnection() { try diff --git a/BaronSoftware.SSO/BaronSSOOption.cs b/BaronSoftware.SSO/BaronSSOOption.cs index d76e250..dc755ff 100644 --- a/BaronSoftware.SSO/BaronSSOOption.cs +++ b/BaronSoftware.SSO/BaronSSOOption.cs @@ -41,6 +41,6 @@ namespace BaronSoftware.SSO /// /// 사용자 인증에 대한 추가 검증이 필요한 경우, IUserValidator 인터페이스를 구현하여 Validator 속성에 할당할 수 있습니다. /// - public IUserValidator? Validator { get; set; } + public required IUserValidator? Validator { get; set; } } } diff --git a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs index e1c1bfb..4f95706 100644 --- a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs +++ b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs @@ -27,25 +27,9 @@ namespace BaronSoftware.SSO { try { -#if DEBUG await webview.EnsureCoreWebView2Async(); webview.CoreWebView2.NavigationStarting += OnNavigationStarting; webview.CoreWebView2.Navigate(_authorizeUrl); -#else - - // 1. 웹뷰 환경 설정 객체 생성 - var environment = await CoreWebView2Environment.CreateAsync(null, null, null); - - // 2. 컨트롤러 옵션 생성 및 InPrivate 모드 활성화 - var options = environment.CreateCoreWebView2ControllerOptions(); - options.IsInPrivateModeEnabled = true; // 핵심 설정 - - // 3. 인프라이빗 옵션을 적용하여 WebView2 초기화 - await webview.EnsureCoreWebView2Async(environment, options); - - webview.CoreWebView2.NavigationStarting += OnNavigationStarting; - webview.CoreWebView2.Navigate(_authorizeUrl); -#endif } catch (Exception ex) { diff --git a/BaronSoftware.SSO/Features/Validator/IUserValidator.cs b/BaronSoftware.SSO/Features/Validator/IUserValidator.cs index d9677c0..a68c0a8 100644 --- a/BaronSoftware.SSO/Features/Validator/IUserValidator.cs +++ b/BaronSoftware.SSO/Features/Validator/IUserValidator.cs @@ -4,6 +4,10 @@ namespace BaronSoftware.SSO /// id_token을 검증(서명/발급자/대상/만료)하고 파싱된 JWT를 반환합니다. public interface IUserValidator { - public void Validate(UserInfo user); + /// + /// 인증 실패 시 예외를 던지세요 + /// + /// + public bool Validate(UserInfo user); } } diff --git a/BaronSoftware.SSO/Models/UserInfo.cs b/BaronSoftware.SSO/Models/UserInfo.cs index e47352a..5dc7ceb 100644 --- a/BaronSoftware.SSO/Models/UserInfo.cs +++ b/BaronSoftware.SSO/Models/UserInfo.cs @@ -7,6 +7,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; namespace BaronSoftware.SSO { @@ -16,12 +17,16 @@ namespace BaronSoftware.SSO public string Name { get; private set; } public string Email { get; private set; } public string[] SubEmails { get; private set; } - public bool IsFamily { get; private set; } public string RefreshToken { get; private set; } public string TenantId { get; private set; } - public string[] JoinedTenantIds { get; private set; } - public DateTime LastAuthTime {get; private set; } + /// 'tenants' 클레임 안에 등장하는 모든 테넌트 id 목록(상위/조상 테넌트 포함, 중복 제거). + public string[] AllTenantIds { get; private set; } + + public long LastAuthUnixTimeStamp { get; private set; } + public DateTime LastAuthTime => DateTimeOffset.FromUnixTimeSeconds(LastAuthUnixTimeStamp).LocalDateTime; + + public string IdToken { get; private set; } public string Raw { get; private set; } public string RawTokenResponse { get; private set; } public Dictionary Claims { get; private set; } @@ -32,17 +37,40 @@ namespace BaronSoftware.SSO var result = new UserInfo(); result.Raw = rawjson; result.RawTokenResponse = reposeToken.RawJson; + result.IdToken = reposeToken.IdToken; result.RefreshToken = reposeToken.RefreshToken ?? ""; result.UUID = userjson["sub"]?.GetValue() ?? ""; result.Name = userjson["name"]?.GetValue() ?? ""; result.Email = userjson["email"]?.GetValue() ?? ""; - //result.SubEmails = userjson["sub_emails"]?.AsArray().Select(x => x.GetValue()).ToArray() ?? Array.Empty(); result.TenantId = userjson["tenant_id"]?.GetValue() ?? ""; - result.JoinedTenantIds = userjson["joined_tenants"]?.AsArray().Select(x => x.GetValue()).ToArray() ?? Array.Empty(); + result.AllTenantIds = ExtractTenantIds(userjson); result.Claims = userjson["rp_claims"]?.AsObject().ToDictionary(x => x.Key, x => (object)x.Value["value"]) ?? new Dictionary(); - result.LastAuthTime = DateTimeOffset.FromUnixTimeSeconds(userjson["auth_time"]?.GetValue() ?? 0).DateTime; + result.LastAuthUnixTimeStamp = userjson["auth_time"]?.GetValue() ?? 0; return result; } + + /// + /// 'tenants' 객체 안에 등장하는 모든 테넌트 id를 정규식으로 수집한다(상위/조상 포함, 중복 제거). + /// + private static string[] ExtractTenantIds(JsonNode userjson) + { + // 'tenants' 하위의 모든 "id":"..." 값을 뽑는 정규식 (상위 테넌트 + ancestors 포함) + Regex regex = new("\"id\"\\s*:\\s*\"([^\"]+)\"", RegexOptions.Compiled); + var tenantsJson = userjson?["tenants"]?.ToJsonString(); + if (string.IsNullOrEmpty(tenantsJson)) + return Array.Empty(); + + var ids = new List(); + var seen = new HashSet(); + foreach (Match m in regex.Matches(tenantsJson)) + { + var id = m.Groups[1].Value; + if (!string.IsNullOrEmpty(id) && seen.Add(id)) + ids.Add(id); + } + return ids.ToArray(); + } + //// root = Ory Hydra id_token payload 또는 /userinfo 응답. //// 클레임은 (Descope의 customAttributes 하위가 아니라) 최상위에 평탄하게 들어오며, //// 상세 항목은 profile 스코프가 동의되었을 때만 포함됩니다. @@ -61,7 +89,7 @@ namespace BaronSoftware.SSO Directory.CreateDirectory(Path.GetDirectoryName(GlobalConfigs.SsoFilePath)); File.WriteAllBytes(GlobalConfigs.SsoFilePath, protectedBytes); - + } internal static UserInfo? FromSsoFile() @@ -99,5 +127,15 @@ namespace BaronSoftware.SSO if (File.Exists(GlobalConfigs.SsoFilePath)) File.Delete(GlobalConfigs.SsoFilePath); } + + public bool IsCenter() + { + return AllTenantIds.Contains(GlobalConfigs.CenterTanant_UUID, StringComparer.OrdinalIgnoreCase); + } + + public bool IsFamily() + { + return AllTenantIds.Contains(GlobalConfigs.FamilyTanant_UUID, StringComparer.OrdinalIgnoreCase); + } } }