- LoginWindow: 로그인/로그아웃/쿠키삭제를 전용 STA 스레드(자체 Dispatcher 펌프)에서 실행 → SignIn/SignInAsync가 MTA/STA 어느 스레드에서 호출돼도 동작 (RunOnDedicatedUiThreadAsync, AuthenticateAsync) - SsoClient: 로그인/로그아웃 창을 AuthenticateAsync로 호출, 크로스스레드 Owner 제거 - BaronSSO: SignIn/SignOut을 Task.Run(...).GetAwaiter().GetResult()로 정리, SignInAsync 데드락 주석 추가 - UserInfo: private set 프로퍼티에 [JsonInclude] 적용 → FromSsoFile 역직렬화 복원 정상화, LastAuthTime [JsonIgnore] - AuthTest 샘플 프로젝트 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
6.5 KiB
C#
167 lines
6.5 KiB
C#
using Microsoft.Win32;
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Printing;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace BaronSoftware.SSO
|
|
{
|
|
public class UserInfo
|
|
{
|
|
// System.Text.Json은 기본적으로 private setter에 역직렬화하지 못한다.
|
|
// [JsonInclude]를 붙여야 FromSsoFile()의 Deserialize가 private set 프로퍼티를 복원한다.
|
|
[JsonInclude]
|
|
public string UUID { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string Name { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string Email { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string[] SubEmails { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string RefreshToken { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string TenantId { get; private set; }
|
|
|
|
/// <summary>'tenants' 클레임 안에 등장하는 모든 테넌트 id 목록(상위/조상 테넌트 포함, 중복 제거).</summary>
|
|
[JsonInclude]
|
|
public string[] AllTenantIds { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public long LastAuthUnixTimeStamp { get; private set; }
|
|
|
|
// 계산형 프로퍼티(setter 없음) — 파일에 저장/복원할 필요 없으므로 직렬화 제외
|
|
[JsonIgnore]
|
|
public DateTime LastAuthTime => DateTimeOffset.FromUnixTimeSeconds(LastAuthUnixTimeStamp).LocalDateTime;
|
|
|
|
[JsonInclude]
|
|
public string IdToken { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public string Raw { get; private set; }
|
|
[JsonInclude]
|
|
public string RawTokenResponse { get; private set; }
|
|
|
|
[JsonInclude]
|
|
public Dictionary<string, object> Claims { get; private set; }
|
|
|
|
internal static UserInfo FromJson(string rawjson, TokenResponse reposeToken)
|
|
{
|
|
var userjson = JsonNode.Parse(rawjson);
|
|
var result = new UserInfo();
|
|
result.Raw = rawjson;
|
|
result.RawTokenResponse = reposeToken.RawJson;
|
|
result.IdToken = reposeToken.IdToken;
|
|
result.RefreshToken = reposeToken.RefreshToken ?? "";
|
|
result.UUID = userjson["sub"]?.GetValue<string>() ?? "";
|
|
result.Name = userjson["name"]?.GetValue<string>() ?? "";
|
|
result.Email = userjson["email"]?.GetValue<string>() ?? "";
|
|
result.TenantId = userjson["tenant_id"]?.GetValue<string>() ?? "";
|
|
result.AllTenantIds = ExtractTenantIds(userjson);
|
|
result.Claims = userjson["rp_claims"]?.AsObject().ToDictionary(x => x.Key, x => (object)x.Value["value"]) ?? new Dictionary<string, object>();
|
|
result.LastAuthUnixTimeStamp = userjson["auth_time"]?.GetValue<long>() ?? 0;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 'tenants' 객체 안에 등장하는 모든 테넌트 id를 정규식으로 수집한다(상위/조상 포함, 중복 제거).
|
|
/// </summary>
|
|
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<string>();
|
|
|
|
var ids = new List<string>();
|
|
var seen = new HashSet<string>();
|
|
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 스코프가 동의되었을 때만 포함됩니다.
|
|
//// (baron-sso backend: buildOidcClaimsFromTraits 참고)
|
|
|
|
internal void Save()
|
|
{
|
|
var json = JsonSerializer.Serialize(this);
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
var protectedBytes = ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser);
|
|
|
|
if (File.Exists(GlobalConfigs.SsoFilePath))
|
|
File.Delete(GlobalConfigs.SsoFilePath);
|
|
|
|
if (!Directory.Exists(Path.GetDirectoryName(GlobalConfigs.SsoFilePath)))
|
|
Directory.CreateDirectory(Path.GetDirectoryName(GlobalConfigs.SsoFilePath));
|
|
|
|
File.WriteAllBytes(GlobalConfigs.SsoFilePath, protectedBytes);
|
|
|
|
}
|
|
|
|
internal static UserInfo? FromSsoFile()
|
|
{
|
|
try
|
|
{
|
|
ValidateSsoFile();
|
|
var bytes = ProtectedData.Unprotect(File.ReadAllBytes(GlobalConfigs.SsoFilePath), optionalEntropy: null, DataProtectionScope.CurrentUser);
|
|
var json = Encoding.UTF8.GetString(bytes);
|
|
var user = JsonSerializer.Deserialize<UserInfo>(json);
|
|
return user;
|
|
}
|
|
catch
|
|
{
|
|
return null; // 손상/복호화 불가 시 무시
|
|
}
|
|
}
|
|
|
|
private static void ValidateSsoFile()
|
|
{
|
|
//SSO 인증 저장파일을 검증
|
|
if (!File.Exists(GlobalConfigs.SsoFilePath))
|
|
throw new FileNotFoundException("Not found sso file", GlobalConfigs.SsoFilePath);
|
|
|
|
var file = new FileInfo(GlobalConfigs.SsoFilePath);
|
|
if (DateTime.Now < file.LastWriteTime)
|
|
{
|
|
//파일의 최종 수정시간이 현재 시간보다 미래인 경우, 시스템 시계가 변경되었거나 파일이 조작되었을 가능성이 있음
|
|
throw new InvalidDataException("Invalid sso file: last write time is in the future");
|
|
}
|
|
}
|
|
|
|
public static void Clear()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|