SSO 로그아웃(RP-Initiated end_session) 구현 및 세션 쿠키 정리 보강
- BaronSSO.SignOutAsync: id_token_hint 기반 end_session 로그아웃 + 로컬 세션 정리 - SsoClient.LogoutAsync: end_session_endpoint 이동 후 post_logout 복귀를 WebView에서 가로채기 - BaronSSOOption.PostLogoutRedirectUri 추가 - LoginWindow: 쿠키 삭제를 ClearBrowsingDataAsync(완료 대기)로 변경해 재로그인 자동 SSO 통과 방지 - UserInfo: IsFamily/IsCenter 대소문자 무시 비교를 StringComparer로 수정(빌드 오류 해소) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>())}");
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>JSON 문자열을 들여쓰기(pretty) 형태로 변환. 파싱 실패 시 원본 반환.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
17
BaronSoftware.SSO.Sample/SimpleUserValidator.cs
Normal file
17
BaronSoftware.SSO.Sample/SimpleUserValidator.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UserInfo의 원본 토큰 응답에서 id_token을 추출한다(로그아웃 id_token_hint용).</summary>
|
||||
private static string ExtractIdToken(UserInfo user)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user?.RawTokenResponse))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(user.RawTokenResponse)?["id_token"]?.GetValue<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CheckConnection()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -41,6 +41,6 @@ namespace BaronSoftware.SSO
|
||||
/// <summary>
|
||||
/// 사용자 인증에 대한 추가 검증이 필요한 경우, IUserValidator 인터페이스를 구현하여 Validator 속성에 할당할 수 있습니다.
|
||||
/// </summary>
|
||||
public IUserValidator? Validator { get; set; }
|
||||
public required IUserValidator? Validator { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,10 @@ namespace BaronSoftware.SSO
|
||||
/// <summary>id_token을 검증(서명/발급자/대상/만료)하고 파싱된 JWT를 반환합니다.</summary>
|
||||
public interface IUserValidator
|
||||
{
|
||||
public void Validate(UserInfo user);
|
||||
/// <summary>
|
||||
/// 인증 실패 시 예외를 던지세요
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
public bool Validate(UserInfo user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
/// <summary>'tenants' 클레임 안에 등장하는 모든 테넌트 id 목록(상위/조상 테넌트 포함, 중복 제거).</summary>
|
||||
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<string, object> 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<string>() ?? "";
|
||||
result.Name = userjson["name"]?.GetValue<string>() ?? "";
|
||||
result.Email = userjson["email"]?.GetValue<string>() ?? "";
|
||||
//result.SubEmails = userjson["sub_emails"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<string>();
|
||||
result.TenantId = userjson["tenant_id"]?.GetValue<string>() ?? "";
|
||||
result.JoinedTenantIds = userjson["joined_tenants"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<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.LastAuthTime = DateTimeOffset.FromUnixTimeSeconds(userjson["auth_time"]?.GetValue<long>() ?? 0).DateTime;
|
||||
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 스코프가 동의되었을 때만 포함됩니다.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user