1667 lines
65 KiB
HTML
1667 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fa" dir="rtl">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>تحلیل و رفع مشکل احراز هویت XApiKey در xIds</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap');
|
||
|
||
:root {
|
||
--primary: #0f2a4a;
|
||
--primary-light: #1e4976;
|
||
--primary-dark: #081a2e;
|
||
--accent: #c9a961;
|
||
--accent-light: #e0c78a;
|
||
--accent-dark: #a8893f;
|
||
--bg: #faf9f6;
|
||
--card-bg: #ffffff;
|
||
--text: #1a202c;
|
||
--text-muted: #4a5568;
|
||
--border: #e2e8f0;
|
||
--success: #2f855a;
|
||
--success-light: #48bb78;
|
||
--danger: #c53030;
|
||
--danger-light: #fc8181;
|
||
--warning: #c05621;
|
||
--warning-light: #f6ad55;
|
||
--info: #2c5282;
|
||
--code-bg: #1a202c;
|
||
--code-text: #e2e8f0;
|
||
--shadow: 0 4px 6px rgba(0,0,0,0.05), 0 10px 30px rgba(15,42,74,0.08);
|
||
--shadow-lg: 0 10px 20px rgba(0,0,0,0.08), 0 20px 40px rgba(15,42,74,0.12);
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: 'Vazirmatn', Tahoma, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.9;
|
||
font-size: 15px;
|
||
padding: 30px 15px;
|
||
}
|
||
|
||
.document-container {
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
background: var(--card-bg);
|
||
box-shadow: var(--shadow-lg);
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ─── HEADER ─── */
|
||
.document-header {
|
||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 50%, var(--primary-light) 100%);
|
||
color: white;
|
||
padding: 55px 45px 45px;
|
||
position: relative;
|
||
border-bottom: 4px solid var(--accent);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.document-header::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -100px; left: -100px;
|
||
width: 400px; height: 400px;
|
||
background: radial-gradient(circle, rgba(201,169,97,0.18) 0%, transparent 70%);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.header-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 30px;
|
||
position: relative;
|
||
z-index: 1;
|
||
flex-wrap: wrap;
|
||
gap: 20px;
|
||
}
|
||
|
||
.company-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.company-logo {
|
||
width: 60px; height: 60px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
border-radius: 14px;
|
||
box-shadow: 0 8px 20px rgba(201,169,97,0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 26px;
|
||
font-weight: 900;
|
||
color: white;
|
||
}
|
||
|
||
.company-name {
|
||
font-size: 19px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.company-tagline {
|
||
font-size: 12px;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.doc-meta {
|
||
text-align: left;
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.doc-meta div { margin-bottom: 4px; }
|
||
.doc-meta .meta-label { color: var(--accent-light); font-weight: 600; }
|
||
|
||
.document-title {
|
||
text-align: center;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.document-title .label {
|
||
display: inline-block;
|
||
background: rgba(201,169,97,0.18);
|
||
color: var(--accent-light);
|
||
padding: 5px 18px;
|
||
border-radius: 20px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
border: 1px solid rgba(201,169,97,0.4);
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.document-title h1 {
|
||
font-size: 28px;
|
||
font-weight: 900;
|
||
margin-bottom: 12px;
|
||
letter-spacing: -0.5px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.document-title .subtitle {
|
||
font-size: 15px;
|
||
opacity: 0.85;
|
||
max-width: 700px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
/* ─── BODY ─── */
|
||
.document-body {
|
||
padding: 50px;
|
||
}
|
||
|
||
.intro-text {
|
||
background: linear-gradient(to left, #f7fafc, #edf2f7);
|
||
border-right: 4px solid var(--accent);
|
||
padding: 22px 28px;
|
||
border-radius: 10px;
|
||
margin-bottom: 40px;
|
||
font-size: 14.5px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.intro-text strong { color: var(--primary); }
|
||
|
||
/* ─── SECTIONS ─── */
|
||
.section {
|
||
margin-bottom: 45px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding-bottom: 14px;
|
||
border-bottom: 2px solid var(--border);
|
||
margin-bottom: 22px;
|
||
}
|
||
|
||
.section-number {
|
||
width: 52px; height: 52px;
|
||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||
color: white;
|
||
border-radius: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 800;
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 6px 14px rgba(15,42,74,0.25);
|
||
}
|
||
|
||
.section-number.danger {
|
||
background: linear-gradient(135deg, var(--danger), #9b2c2c);
|
||
}
|
||
|
||
.section-number.success {
|
||
background: linear-gradient(135deg, var(--success), #22543d);
|
||
}
|
||
|
||
.section-title { flex: 1; }
|
||
|
||
.section-title h3 {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.section-title .subtitle {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.section-content { padding-right: 8px; }
|
||
|
||
.section-content p {
|
||
margin-bottom: 14px;
|
||
text-align: justify;
|
||
}
|
||
|
||
.section-content h4 {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin: 22px 0 12px;
|
||
}
|
||
|
||
.section-content ul, .section-content ol {
|
||
padding-right: 24px;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.section-content li {
|
||
margin-bottom: 8px;
|
||
line-height: 1.85;
|
||
}
|
||
|
||
/* ─── BOXES ─── */
|
||
.highlight-box, .info-box, .success-box, .warning-box, .danger-box {
|
||
padding: 20px 24px;
|
||
border-radius: 10px;
|
||
margin: 22px 0;
|
||
font-size: 14.5px;
|
||
}
|
||
|
||
.highlight-box {
|
||
background: linear-gradient(to left, #fffaf0, #fef5e7);
|
||
border: 1px solid #f6e05e;
|
||
border-right: 4px solid var(--accent);
|
||
}
|
||
.highlight-box strong { color: var(--accent-dark); }
|
||
|
||
.info-box {
|
||
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
|
||
border: 1px solid #90cdf4;
|
||
border-right: 4px solid var(--info);
|
||
}
|
||
|
||
.success-box {
|
||
background: linear-gradient(to left, #f0fff4, #e6fffa);
|
||
border: 1px solid #9ae6b4;
|
||
border-right: 4px solid var(--success);
|
||
}
|
||
|
||
.warning-box {
|
||
background: linear-gradient(to left, #fffaf0, #fef5e7);
|
||
border: 1px solid #fbd38d;
|
||
border-right: 4px solid var(--warning);
|
||
}
|
||
|
||
.danger-box {
|
||
background: linear-gradient(to left, #fff5f5, #fed7d7);
|
||
border: 1px solid #fc8181;
|
||
border-right: 4px solid var(--danger);
|
||
}
|
||
.danger-box strong { color: var(--danger); }
|
||
|
||
/* ─── CODE ─── */
|
||
.code-block {
|
||
background: var(--code-bg);
|
||
color: var(--code-text);
|
||
border-radius: 10px;
|
||
padding: 20px 24px;
|
||
margin: 16px 0;
|
||
overflow-x: auto;
|
||
direction: ltr;
|
||
text-align: left;
|
||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
position: relative;
|
||
border: 1px solid #2d3748;
|
||
}
|
||
|
||
.code-block::before {
|
||
content: attr(data-lang);
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 12px;
|
||
font-size: 10px;
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
letter-spacing: 1px;
|
||
direction: ltr;
|
||
}
|
||
|
||
.code-block .keyword { color: #f687b3; }
|
||
.code-block .type { color: #63b3ed; }
|
||
.code-block .string { color: #9ae6b4; }
|
||
.code-block .comment { color: #718096; font-style: italic; }
|
||
.code-block .method { color: #f6e05e; }
|
||
.code-block .number { color: #fbb6ce; }
|
||
|
||
code {
|
||
background: #edf2f7;
|
||
color: var(--primary);
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
direction: ltr;
|
||
display: inline-block;
|
||
}
|
||
|
||
/* ─── TABLES ─── */
|
||
.styled-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 20px 0;
|
||
background: white;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow);
|
||
font-size: 13.5px;
|
||
}
|
||
|
||
.styled-table thead {
|
||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||
color: white;
|
||
}
|
||
|
||
.styled-table th {
|
||
padding: 14px 16px;
|
||
text-align: right;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.styled-table td {
|
||
padding: 13px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
}
|
||
|
||
.styled-table tbody tr:last-child td { border-bottom: none; }
|
||
.styled-table tbody tr:nth-child(even) { background: #f8fafc; }
|
||
|
||
.styled-table code {
|
||
font-size: 12px;
|
||
padding: 1px 6px;
|
||
}
|
||
|
||
/* ─── STEPS ─── */
|
||
.step-card {
|
||
background: white;
|
||
border: 2px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 25px 28px;
|
||
margin-bottom: 20px;
|
||
position: relative;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.step-card:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.step-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.step-number {
|
||
width: 44px; height: 44px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
color: white;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 800;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 6px 14px rgba(201,169,97,0.3);
|
||
}
|
||
|
||
.step-title {
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.step-title .step-subtitle {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--text-muted);
|
||
display: block;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.step-body p {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* ─── ISSUE CARD ─── */
|
||
.issue-card {
|
||
background: linear-gradient(to left, #fff5f5, #fed7d7);
|
||
border: 2px solid #fc8181;
|
||
border-radius: 12px;
|
||
padding: 25px;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.issue-card .issue-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
color: var(--danger);
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.issue-card .issue-icon {
|
||
font-size: 24px;
|
||
}
|
||
|
||
/* ─── FILE REF ─── */
|
||
.file-ref {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: #edf2f7;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 3px 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--primary);
|
||
direction: ltr;
|
||
margin: 2px 0;
|
||
}
|
||
|
||
/* ─── DIAGNOSIS ─── */
|
||
.diagnosis-tree {
|
||
background: linear-gradient(135deg, #f8fafc, #edf2f7);
|
||
border: 2px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 25px;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.diagnosis-item {
|
||
display: flex;
|
||
gap: 15px;
|
||
padding: 15px 0;
|
||
border-bottom: 1px dashed var(--border);
|
||
}
|
||
|
||
.diagnosis-item:last-child { border-bottom: none; }
|
||
|
||
.diagnosis-badge {
|
||
flex-shrink: 0;
|
||
width: 32px; height: 32px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 800;
|
||
font-size: 14px;
|
||
color: white;
|
||
}
|
||
|
||
.diagnosis-badge.fail { background: var(--danger); }
|
||
.diagnosis-badge.pass { background: var(--success); }
|
||
.diagnosis-badge.warn { background: var(--warning); }
|
||
|
||
.diagnosis-content { flex: 1; }
|
||
|
||
.diagnosis-content h5 {
|
||
font-size: 14.5px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.diagnosis-content p {
|
||
font-size: 13.5px;
|
||
color: var(--text-muted);
|
||
margin: 0;
|
||
}
|
||
|
||
/* ─── DIAGRAM ─── */
|
||
.flow-diagram {
|
||
background: white;
|
||
border: 2px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 30px 20px;
|
||
margin: 20px 0;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.flow-steps {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.flow-step-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.flow-step-box {
|
||
flex: 1;
|
||
background: #f8fafc;
|
||
border: 2px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 14px 18px;
|
||
position: relative;
|
||
}
|
||
|
||
.flow-step-box.fail {
|
||
background: linear-gradient(to left, #fff5f5, #fed7d7);
|
||
border-color: #fc8181;
|
||
}
|
||
|
||
.flow-step-box.success {
|
||
background: linear-gradient(to left, #f0fff4, #e6fffa);
|
||
border-color: #9ae6b4;
|
||
}
|
||
|
||
.flow-step-box h5 {
|
||
font-size: 13.5px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.flow-step-box p {
|
||
font-size: 12.5px;
|
||
color: var(--text-muted);
|
||
margin: 0;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.flow-arrow-down {
|
||
text-align: center;
|
||
font-size: 20px;
|
||
color: var(--accent);
|
||
margin: 2px 0;
|
||
}
|
||
|
||
/* ─── BADGES ─── */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.badge-fail { background: #fed7d7; color: var(--danger); }
|
||
.badge-pass { background: #c6f6d5; color: var(--success); }
|
||
.badge-warn { background: #feebc8; color: var(--warning); }
|
||
.badge-info { background: #bee3f8; color: var(--info); }
|
||
|
||
/* ─── FOOTER ─── */
|
||
.document-footer {
|
||
background: var(--primary-dark);
|
||
color: white;
|
||
padding: 30px 40px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.document-footer .footer-brand {
|
||
font-weight: 800;
|
||
color: var(--accent);
|
||
margin-bottom: 8px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.footer-divider {
|
||
width: 80px;
|
||
height: 2px;
|
||
background: var(--accent);
|
||
margin: 14px auto;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.page-info {
|
||
text-align: center;
|
||
padding: 18px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
border-top: 1px solid var(--border);
|
||
background: #f8fafc;
|
||
}
|
||
|
||
@media print {
|
||
body { background: white; padding: 0; }
|
||
.document-container { box-shadow: none; border-radius: 0; }
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.document-body { padding: 30px 22px; }
|
||
.document-header { padding: 35px 25px; }
|
||
.header-top { flex-direction: column; }
|
||
.doc-meta { text-align: right; }
|
||
.document-title h1 { font-size: 22px; }
|
||
.flow-step-row { flex-direction: column; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="document-container">
|
||
|
||
<!-- ══════════ HEADER ══════════ -->
|
||
<header class="document-header">
|
||
<div class="header-top">
|
||
<div class="company-badge">
|
||
<div class="company-logo">X</div>
|
||
<div>
|
||
<div class="company-name">xSaherelmWorkspace</div>
|
||
<div class="company-tagline">Technical Analysis Document</div>
|
||
</div>
|
||
</div>
|
||
<div class="doc-meta">
|
||
<div><span class="meta-label">شماره مستند:</span> XIDS-AUTH-2026-001</div>
|
||
<div><span class="meta-label">نسخه:</span> ۱.۰</div>
|
||
<div><span class="meta-label">موضوع:</span> رفع مشکل احراز هویت XApiKey</div>
|
||
<div><span class="meta-label">طبقهبندی:</span> داخلی — فنی</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="document-title">
|
||
<span class="label">TECHNICAL DIAGNOSIS & SOLUTION</span>
|
||
<h1>ریشهیابی و رفع مشکل احراز هویت با XApiKey در xIds</h1>
|
||
<div class="subtitle">
|
||
تحلیل جامع کد، شناسایی گلوگاه احراز هویت، و ارائه راهحل گامبهگام برای فعالسازی کامل
|
||
Authentication مبتنی بر API Key در سرور IdentityServer4
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ══════════ BODY ══════════ -->
|
||
<div class="document-body">
|
||
|
||
<div class="intro-text">
|
||
<strong>خلاصه اجرایی:</strong><br>
|
||
پس از بررسی کامل کدهای پروژه <strong>xIds</strong>، مشخص شد که زیرساخت مدیریت Application و
|
||
تولید <code>XApiKey</code> بهدرستی پیادهسازی شده است، اما <strong>زنجیره احراز هویت مبتنی بر API Key
|
||
در سمت سرور IdentityServer4 ناقص مانده است</strong>. این سند ابتدا ریشه مشکل را با استناد به کدهای
|
||
موجود تشریح میکند، سپس راهحل کامل گامبهگام را ارائه میدهد.
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 1: CURRENT STATE -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number">۱</div>
|
||
<div class="section-title">
|
||
<h3>وضعیت فعلی سیستم</h3>
|
||
<div class="subtitle">Current State Analysis</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
<p>
|
||
در پروژه xIds، اجزای زیر برای مدیریت Application و API Key پیادهسازی شدهاند:
|
||
</p>
|
||
|
||
<h4>۱.۱ — اجزای پیادهسازیشده</h4>
|
||
|
||
<table class="styled-table">
|
||
<thead>
|
||
<tr>
|
||
<th>جزء</th>
|
||
<th>مسیر فایل</th>
|
||
<th>وضعیت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>مدلهای Application و ApiKey</td>
|
||
<td><span class="file-ref">xIdentityModels/Models/XApplication.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>XApplicationProvider (مدیریت API Key)</td>
|
||
<td><span class="file-ref">xIds/Providers/XApplicationProvider.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>XApiKeyHelper (تولید و هش)</td>
|
||
<td><span class="file-ref">xIds/Helpers/XApiKeyHelper.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>ApplicationsController (Endpointها)</td>
|
||
<td><span class="file-ref">xIds/Controllers/ApplicationsController.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>XApiKeyAuthenticationHandler</td>
|
||
<td><span class="file-ref">xIds/Providers/XApiKeyAuthenticationHandler.cs</span></td>
|
||
<td><span class="badge badge-warn">⚠ موجود اما ناقص</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>XApiKeyConfiguration</td>
|
||
<td><span class="file-ref">xIds/Configurations/XApiKeyConfiguration.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>ثبت Scheme در Startup</td>
|
||
<td><span class="file-ref">xIds/Startup.cs</span></td>
|
||
<td><span class="badge badge-pass">✓ موجود</span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h4>۱.۲ — معماری جاری احراز هویت در Startup.cs</h4>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/Startup.cs</span>
|
||
services.<span class="method">AddXIdentityServerAuthentication</span>(
|
||
configuration: Configuration,
|
||
addApiKeyAuthentication: <span class="keyword">true</span> <span class="comment">// ← این خط فعال است</span>
|
||
);
|
||
</div>
|
||
|
||
<p>
|
||
و در <code>XDIHelperExtension.AddXIdentityServerAuthentication</code>:
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="keyword">var</span> authBuilder = services.<span class="method">AddAuthentication</span>();
|
||
|
||
authBuilder.<span class="method">AddJwtBearer</span>(options =>
|
||
{
|
||
options.SaveToken = <span class="keyword">true</span>;
|
||
options.RequireHttpsMetadata = <span class="keyword">false</span>;
|
||
options.ForwardDefault = XAuthentication.IDENTITY_SERVER_LOCAL_API;
|
||
})
|
||
.<span class="method">AddLocalApi</span>();
|
||
|
||
<span class="keyword">if</span> (addApiKeyAuthentication)
|
||
{
|
||
authBuilder.<span class="method">AddScheme</span><AuthenticationSchemeOptions, XApiKeyAuthenticationHandler>(
|
||
XAuthenticationScheme.XApiKey.<span class="method">GetStringValue</span>(), <span class="comment">// "XApiKey"</span>
|
||
options => {}
|
||
);
|
||
}
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<strong>نتیجه:</strong> به نظر میرسد همه چیز در Startup بهدرستی ثبت شده است. اما در عمل، درخواستهایی
|
||
که Header <code>X-Api-Key</code> دارند، احراز هویت نمیشوند. علت چیست؟
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 2: ROOT CAUSE -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number danger">۲</div>
|
||
<div class="section-title">
|
||
<h3>ریشهیابی مشکل</h3>
|
||
<div class="subtitle">Root Cause Analysis</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
<p>
|
||
پس از بررسی دقیق کدها، <strong>سه ریشه اصلی</strong> برای عدم کارکرد احراز هویت با XApiKey شناسایی شد:
|
||
</p>
|
||
|
||
<!-- Issue 1 -->
|
||
<div class="issue-card">
|
||
<div class="issue-title">
|
||
<span class="issue-icon">🔴</span>
|
||
مشکل ۱: ForwardDefaultSelector به XApiKey توجه نمیکند
|
||
</div>
|
||
<p>
|
||
در <code>AddJwtBearer</code>، تنظیم <code>ForwardDefault</code> روی
|
||
<code>XAuthentication.IDENTITY_SERVER_LOCAL_API</code> قرار داده شده است. این یعنی
|
||
ASP.NET Core همیشه از Scheme پیشفرض (<code>IdentityServerAccessToken</code>) استفاده میکند
|
||
و هیچگاه به Scheme با نام <code>XApiKey</code> نمیرسد.
|
||
</p>
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/DI/XDIHelperExtension.cs — AddXIdentityServerAuthentication</span>
|
||
authBuilder.<span class="method">AddJwtBearer</span>(options =>
|
||
{
|
||
options.SaveToken = <span class="keyword">true</span>;
|
||
options.RequireHttpsMetadata = <span class="keyword">false</span>;
|
||
options.ForwardDefault = XAuthentication.IDENTITY_SERVER_LOCAL_API; <span class="comment">// ← مشکل اصلی اینجاست</span>
|
||
})
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Issue 2 -->
|
||
<div class="issue-card">
|
||
<div class="issue-title">
|
||
<span class="issue-icon">🔴</span>
|
||
مشکل ۲: ترتیب ثبت Schemeها اشتباه است
|
||
</div>
|
||
<p>
|
||
در ASP.NET Core، <strong>اولین Scheme ثبتشده، Scheme پیشفرض است</strong>. در کد فعلی،
|
||
<code>AddJwtBearer</code> قبل از <code>AddScheme<...XApiKeyAuthenticationHandler></code> ثبت
|
||
میشود و این باعث میشود حتی اگر Header <code>X-Api-Key</code> ارسال شود، Handler مربوطه
|
||
هرگز فراخوانی نگردد.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Issue 3 -->
|
||
<div class="issue-card">
|
||
<div class="issue-title">
|
||
<span class="issue-icon">🔴</span>
|
||
مشکل ۳: XApiKeyAuthenticationHandler به Claimهای Policyهای موجود پاسخ نمیدهد
|
||
</div>
|
||
<p>
|
||
Policyهای موجود در <code>XAuthorizationHelper</code> (مانند <code>ApiKeyAccess</code> و
|
||
<code>ApiKeyReadAccess</code>) انتظار Claim از نوع <code>JwtClaimTypes.Scope</code> با مقادیر
|
||
<code>read</code>، <code>write</code>، <code>admin</code> یا <code>manage</code> دارند.
|
||
اما <code>XApiKeyAuthenticationHandler</code> فعلی در کد، یک Scope ثابت به نام
|
||
<code>"apikey"</code> اضافه میکند که با هیچ Policy موجودی مطابقت ندارد.
|
||
</p>
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/Providers/XApiKeyAuthenticationHandler.cs — کد فعلی</span>
|
||
<span class="keyword">var</span> claims = <span class="keyword">new</span>[]
|
||
{
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(ClaimTypes.Name, validationResult.OwnerId),
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(<span class="string">"application_id"</span>, validationResult.ApplicationId.<span class="method">ToString</span>()),
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(<span class="string">"auth_type"</span>, <span class="string">"apikey"</span>),
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(JwtClaimTypes.Scope, <span class="string">"apikey"</span>), <span class="comment">// ← هیچ Policyای این Scope را نمیشناسد</span>
|
||
};
|
||
|
||
<span class="comment">// سپس scopes از validationResult اضافه میشود، اما با مقادیر "read", "write"...</span>
|
||
<span class="comment">// که با Policyهای XAuthorizationHelper همخوانی ندارند</span>
|
||
</div>
|
||
</div>
|
||
|
||
<h4>۲.۱ — درخت عیبیابی</h4>
|
||
|
||
<div class="diagnosis-tree">
|
||
<div class="diagnosis-item">
|
||
<div class="diagnosis-badge fail">✗</div>
|
||
<div class="diagnosis-content">
|
||
<h5>درخواست با Header: X-Api-Key: xapp_xxxxx</h5>
|
||
<p>Header به درستی در Request وجود دارد.</p>
|
||
</div>
|
||
</div>
|
||
<div class="diagnosis-item">
|
||
<div class="diagnosis-badge fail">✗</div>
|
||
<div class="diagnosis-content">
|
||
<h5>ASP.NET Core میخواهد Scheme پیشفرض را انتخاب کند</h5>
|
||
<p>به دلیل <code>ForwardDefault = IDENTITY_SERVER_LOCAL_API</code>، همیشه Scheme پیشفرض انتخاب میشود.</p>
|
||
</div>
|
||
</div>
|
||
<div class="diagnosis-item">
|
||
<div class="diagnosis-badge fail">✗</div>
|
||
<div class="diagnosis-content">
|
||
<h5>XApiKeyAuthenticationHandler فراخوانی نمیشود</h5>
|
||
<p>چون هیچگاه انتخاب نمیشود، متد HandleAuthenticateAsync اجرا نمیگردد.</p>
|
||
</div>
|
||
</div>
|
||
<div class="diagnosis-item">
|
||
<div class="diagnosis-badge fail">✗</div>
|
||
<div class="diagnosis-content">
|
||
<h5>درخواست با خطای 401 Unauthorized رد میشود</h5>
|
||
<p>پایان مسیر — احراز هویت شکست خورده است.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="danger-box">
|
||
<strong>⚠️ خلاصه ریشه مشکل:</strong> در معماری فعلی، ASP.NET Core با
|
||
<code>ForwardDefault</code> به Scheme پیشفرض هدایت میشود و هرگز به Scheme
|
||
<code>XApiKey</code> نمیرسد. علاوه بر این، حتی اگر Handler فراخوانی شود، Claimهای
|
||
تولیدشده با Policyهای موجود همخوانی ندارند و در نتیجه Authorization نیز شکست میخورد.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 3: FLOW DIAGRAM -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number">۳</div>
|
||
<div class="section-title">
|
||
<h3>نمودار جریان فعلی در مقابل جریان مطلوب</h3>
|
||
<div class="subtitle">Current vs. Desired Auth Flow</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
<h4>۳.۱ — جریان فعلی (معیوب)</h4>
|
||
|
||
<div class="flow-diagram">
|
||
<div class="flow-steps">
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box">
|
||
<h5>۱. Client Request</h5>
|
||
<p>ارسال درخواست با Header <code>X-Api-Key</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box fail">
|
||
<h5>۲. Default Scheme Selection</h5>
|
||
<p>ASP.NET Core به دلیل ForwardDefault، Scheme پیشفرض را انتخاب میکند</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box fail">
|
||
<h5>۳. JwtBearer Handler اجرا میشود</h5>
|
||
<p>بهدنبال Bearer Token میگردد، پیدا نمیکند، شکست میخورد</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box fail">
|
||
<h5>❌ نتیجه: 401 Unauthorized</h5>
|
||
<p>XApiKeyAuthenticationHandler هرگز اجرا نمیشود</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h4>۳.۲ — جریان مطلوب (پس از راهحل)</h4>
|
||
|
||
<div class="flow-diagram">
|
||
<div class="flow-steps">
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box">
|
||
<h5>۱. Client Request</h5>
|
||
<p>ارسال درخواست با Header <code>X-Api-Key</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box success">
|
||
<h5>۲. ForwardDefaultSelector اجرا میشود</h5>
|
||
<p>وجود Header <code>X-Api-Key</code> را تشخیص میدهد و Scheme <code>XApiKey</code> را انتخاب میکند</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box success">
|
||
<h5>۳. XApiKeyAuthenticationHandler.HandleAuthenticateAsync</h5>
|
||
<p>API Key را استخراج و <code>ValidateApiKey</code> را فراخوانی میکند</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box success">
|
||
<h5>۴. Claims تولید میشوند</h5>
|
||
<p>Scopeهای واقعی از <code>validationResult.Scopes</code> به Claims اضافه میشوند</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box success">
|
||
<h5>۵. Policy-Based Authorization</h5>
|
||
<p>Policyهای <code>ApiKeyAccess</code> و <code>ApiKeyReadAccess</code> با موفقیت بررسی میشوند</p>
|
||
</div>
|
||
</div>
|
||
<div class="flow-arrow-down">▼</div>
|
||
<div class="flow-step-row">
|
||
<div class="flow-step-box success">
|
||
<h5>✅ نتیجه: 200 OK + Response</h5>
|
||
<p>دسترسی به Endpoint مورد نظر با موفقیت انجام میشود</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 4: SOLUTION -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number success">۴</div>
|
||
<div class="section-title">
|
||
<h3>راهحل گامبهگام</h3>
|
||
<div class="subtitle">Step-by-Step Solution</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
|
||
<!-- STEP 1 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۱</div>
|
||
<div class="step-title">
|
||
اصلاح ForwardDefaultSelector در Startup
|
||
<span class="step-subtitle">فایل: xIds/DI/XDIHelperExtension.cs</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
باید یک <code>ForwardDefaultSelector</code> تنظیم شود که بر اساس وجود Header
|
||
<code>X-Api-Key</code> در Request، Scheme صحیح را انتخاب کند. این Selector
|
||
برای <strong>تمام Schemeها</strong> (نه فقط JwtBearer) باید فعال باشد.
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/DI/XDIHelperExtension.cs — بخش AddXIdentityServerAuthentication</span>
|
||
|
||
<span class="keyword">public static void</span> <span class="method">AddXIdentityServerAuthentication</span>(
|
||
<span class="keyword">this</span> <span class="type">IServiceCollection</span> services,
|
||
<span class="type">IConfiguration</span> configuration,
|
||
<span class="keyword">bool</span> addApiKeyAuthentication = <span class="keyword">false</span>
|
||
)
|
||
{
|
||
services.<span class="method">AddXIdentityResourceConfiguration</span>(configuration);
|
||
|
||
<span class="keyword">var</span> authBuilder = services.<span class="method">AddAuthentication</span>();
|
||
|
||
<span class="comment">// ═══ ۱. ثبت Scheme برای XApiKey (اول از همه)</span>
|
||
<span class="keyword">if</span> (addApiKeyAuthentication)
|
||
{
|
||
authBuilder.<span class="method">AddScheme</span><<span class="type">AuthenticationSchemeOptions</span>, <span class="type">XApiKeyAuthenticationHandler</span>>(
|
||
XAuthenticationScheme.XApiKey.<span class="method">GetStringValue</span>(),
|
||
options => {}
|
||
);
|
||
}
|
||
|
||
<span class="comment">// ═══ ۲. ثبت JwtBearer با ForwardDefaultSelector هوشمند</span>
|
||
authBuilder.<span class="method">AddJwtBearer</span>(options =>
|
||
{
|
||
options.SaveToken = <span class="keyword">true</span>;
|
||
options.RequireHTTPSMetadata = <span class="keyword">false</span>;
|
||
options.ForwardDefault = XAuthentication.IDENTITY_SERVER_LOCAL_API;
|
||
|
||
<span class="comment">// ═══ ۳. انتخاب Scheme بر اساس Header درخواست</span>
|
||
options.ForwardDefaultSelector = context =>
|
||
{
|
||
<span class="comment">// اگر Header X-Api-Key وجود دارد → از Scheme XApiKey استفاده کن</span>
|
||
<span class="keyword">var</span> apiKeyHeader = XHeader.ApiKey.<span class="method">GetStringValue</span>(); <span class="comment">// "X-Api-Key"</span>
|
||
<span class="keyword">if</span> (context.Request.Headers.<span class="method">ContainsKey</span>(apiKeyHeader))
|
||
{
|
||
<span class="keyword">return</span> XAuthenticationScheme.XApiKey.<span class="method">GetStringValue</span>();
|
||
}
|
||
|
||
<span class="comment">// در غیر این صورت، Scheme پیشفرض (LocalApi برای IdentityServer)</span>
|
||
<span class="keyword">return</span> XAuthentication.IDENTITY_SERVER_LOCAL_API;
|
||
};
|
||
})
|
||
.<span class="method">AddLocalApi</span>();
|
||
}
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<strong>💡 چرا این راهحل کار میکند؟</strong> <code>ForwardDefaultSelector</code>
|
||
یک delegate است که ASP.NET Core در هر درخواست فراخوانی میکند تا Scheme مناسب را
|
||
تعیین کند. با بررسی وجود Header، ما بهصورت پویا Scheme را انتخاب میکنیم.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 2 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۲</div>
|
||
<div class="step-title">
|
||
اصلاح XApiKeyAuthenticationHandler برای تولید Claims صحیح
|
||
<span class="step-subtitle">فایل: xIds/Providers/XApiKeyAuthenticationHandler.cs</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
Handler فعلی Scope ثابت <code>"apikey"</code> را اضافه میکند که با هیچ Policyای
|
||
همخوانی ندارد. باید Scopeهای واقعی از <code>validationResult.Scopes</code>
|
||
که در <code>XApplicationProvider.ValidateApiKey</code> استخراج شدهاند، به Claims
|
||
اضافه شوند.
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/Providers/XApiKeyAuthenticationHandler.cs — نسخه اصلاحشده</span>
|
||
|
||
<span class="keyword">protected override async</span> <span class="type">Task</span><<span class="type">AuthenticateResult</span>> <span class="method">HandleAuthenticateAsync</span>()
|
||
{
|
||
<span class="comment">// ۱. استخراج API Key از Header</span>
|
||
<span class="keyword">var</span> apiKeyHeader = <span class="method">Options</span>.HeaderName ?? XHeader.ApiKey.<span class="method">GetStringValue</span>();
|
||
<span class="keyword">if</span> (!Request.Headers.<span class="method">ContainsKey</span>(apiKeyHeader))
|
||
{
|
||
<span class="keyword">return</span> AuthenticateResult.<span class="method">NoResult</span>();
|
||
}
|
||
|
||
<span class="keyword">var</span> apiKey = Request.Headers[apiKeyHeader].<span class="method">ToString</span>();
|
||
<span class="keyword">if</span> (<span class="keyword">string</span>.<span class="method">IsNullOrEmpty</span>(apiKey))
|
||
{
|
||
<span class="keyword">return</span> AuthenticateResult.<span class="method">NoResult</span>();
|
||
}
|
||
|
||
<span class="comment">// ۲. استخراج Client IP</span>
|
||
<span class="keyword">var</span> clientIP = Request.HttpContext.Connection.RemoteIpAddress?.<span class="method">ToString</span>();
|
||
|
||
<span class="comment">// ۳. اعتبارسنجی API Key از طریق Provider</span>
|
||
<span class="keyword">var</span> validationResult = <span class="keyword">await</span> applicationProvider.<span class="method">ValidateApiKey</span>(
|
||
apiKey: apiKey,
|
||
clientIP: clientIP
|
||
);
|
||
|
||
<span class="keyword">if</span> (validationResult.Errors.<span class="method">HasChild</span>())
|
||
{
|
||
<span class="keyword">var</span> message = validationResult.Errors.<span class="method">ToListString</span>(<span class="string">'\n'</span>);
|
||
<span class="keyword">return</span> AuthenticateResult.<span class="method">Fail</span>(message);
|
||
}
|
||
|
||
<span class="comment">// ═══ ۴. ساخت Claims — نکته کلیدی ═══</span>
|
||
<span class="keyword">var</span> claims = <span class="keyword">new</span> <span class="type">List</span><<span class="type">Claim</span>>
|
||
{
|
||
<span class="comment">// شناسه کاربر مالک API Key</span>
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(<span class="type">ClaimTypes</span>.Name, validationResult.OwnerId),
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(JwtClaimTypes.Subject, validationResult.OwnerId),
|
||
|
||
<span class="comment">// شناسه Application</span>
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(<span class="string">"application_id"</span>, validationResult.ApplicationId.<span class="method">ToString</span>()),
|
||
|
||
<span class="comment">// نوع احراز هویت برای تشخیص</span>
|
||
<span class="keyword">new</span> <span class="type">Claim</span>(<span class="string">"auth_type"</span>, <span class="string">"apikey"</span>),
|
||
};
|
||
|
||
<span class="comment">// ═══ ۵. اضافه کردن Scopeهای واقعی ═══</span>
|
||
<span class="comment">// این Scopeها توسط XApplicationProvider از AllowedScopes استخراج شدهاند</span>
|
||
<span class="comment">// و با Policyهای XAuthorizationHelper (read, write, admin, manage) همخوانی دارند</span>
|
||
<span class="keyword">if</span> (validationResult.Scopes != <span class="keyword">null</span>)
|
||
{
|
||
<span class="keyword">foreach</span> (<span class="keyword">var</span> scope <span class="keyword">in</span> validationResult.Scopes)
|
||
{
|
||
claims.<span class="method">Add</span>(<span class="keyword">new</span> <span class="type">Claim</span>(JwtClaimTypes.Scope, scope));
|
||
}
|
||
}
|
||
|
||
<span class="comment">// ۶. ساخت Principal و Ticket</span>
|
||
<span class="keyword">var</span> identity = <span class="keyword">new</span> <span class="type">ClaimsIdentity</span>(claims, Scheme.Name);
|
||
<span class="keyword">var</span> principal = <span class="keyword">new</span> <span class="type">ClaimsPrincipal</span>(identity);
|
||
<span class="keyword">var</span> ticket = <span class="keyword">new</span> <span class="type">AuthenticationTicket</span>(principal, Scheme.Name);
|
||
|
||
<span class="keyword">return</span> AuthenticateResult.<span class="method">Success</span>(ticket);
|
||
}
|
||
</div>
|
||
|
||
<div class="success-box">
|
||
<strong>✅ نکته کلیدی:</strong> <code>validationResult.Scopes</code> مقادیری مانند
|
||
<code>read</code>، <code>write</code>، <code>admin</code>، <code>manage</code> دارد که
|
||
مستقیماً با Policyهای تعریفشده در <code>XAuthorizationHelper</code> مطابقت میکند.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 3 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۳</div>
|
||
<div class="step-title">
|
||
اطمینان از همخوانی Policyها با Scopeهای API Key
|
||
<span class="step-subtitle">فایل: xIdentityHelper/XAuthorizationHelper.cs</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
در <code>XAuthorizationHelper</code>، Policyهای مربوط به ApiKey تعریف شدهاند.
|
||
این Policyها باید با مقادیر <code>XApiKeyScope</code> در
|
||
<code>xIdentityModels/Constants/XApiKeyScope.cs</code> همخوانی داشته باشند.
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIdentityModels/Constants/XApiKeyScope.cs</span>
|
||
<span class="keyword">public enum</span> <span class="type">XApiKeyScope</span>
|
||
{
|
||
[<span class="method">StringValue</span>(<span class="string">"read"</span>)]
|
||
Read,
|
||
|
||
[<span class="method">StringValue</span>(<span class="string">"write"</span>)]
|
||
Write,
|
||
|
||
[<span class="method">StringValue</span>(<span class="string">"admin"</span>)]
|
||
Admin,
|
||
|
||
[<span class="method">StringValue</span>(<span class="string">"manage"</span>)]
|
||
Manage
|
||
}
|
||
</div>
|
||
|
||
<p>
|
||
و در <code>XAuthorizationHelper</code>:
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIdentityHelper/XAuthorizationHelper.cs — Policyهای موجود ApiKey</span>
|
||
|
||
<span class="comment">// ApiKey Access Policy — دسترسی به هر یک از Scopeها</span>
|
||
result.<span class="method">Add</span>(XPolicies.ApiKeyAccess,
|
||
<span class="keyword">new</span> <span class="type">AuthorizationPolicyBuilder</span>()
|
||
.<span class="method">RequireClaim</span>(
|
||
JwtClaimTypes.Scope,
|
||
XApiKeyScope.Read.<span class="method">GetStringValue</span>(), <span class="comment">// "read"</span>
|
||
XApiKeyScope.Write.<span class="method">GetStringValue</span>(), <span class="comment">// "write"</span>
|
||
XApiKeyScope.Manage.<span class="method">GetStringValue</span>() <span class="comment">// "manage"</span>
|
||
)
|
||
.<span class="method">Build</span>()
|
||
);
|
||
|
||
<span class="comment">// ApiKey Read Access Policy</span>
|
||
result.<span class="method">Add</span>(XPolicies.ApiKeyReadAccess,
|
||
<span class="keyword">new</span> <span class="type">AuthorizationPolicyBuilder</span>()
|
||
.<span class="method">RequireClaim</span>(JwtClaimTypes.Scope, XApiKeyScope.Read.<span class="method">GetStringValue</span>())
|
||
.<span class="method">Build</span>()
|
||
);
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<strong>💡 نتیجه:</strong> اگر در گام ۲، Scopeهای واقعی از <code>validationResult.Scopes</code>
|
||
به Claims اضافه شوند، این Policyها بهصورت خودکار با موفقیت بررسی میشوند و دسترسی
|
||
به Endpointهای محافظتشده امکانپذیر میگردد.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 4 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۴</div>
|
||
<div class="step-title">
|
||
استفاده صحیح در Controllerها
|
||
<span class="step-subtitle">نمونه استفاده صحیح</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
پس از اصلاحات بالا، برای محافظت از Endpointها با API Key، باید از Policyهای
|
||
مربوطه استفاده کنید:
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// نمونه Endpoint محافظتشده با API Key</span>
|
||
[<span class="type">RequireXPowered</span>]
|
||
[<span class="type">HttpPost</span>(<span class="string">"ApiKeys/Validate"</span>)]
|
||
[<span class="type">Authorize</span>(Policy = XPolicies.ApiKeyAccess)] <span class="comment">// ← Policy مخصوص API Key</span>
|
||
<span class="keyword">public async</span> <span class="type">Task</span><<span class="type">ActionResult</span><<span class="type">XApiKeyValidationResult</span>>> <span class="method">ValidateApiKey</span>(
|
||
[<span class="type">FromBody</span>] <span class="type">XApiKeyValidationRequest</span> request,
|
||
<span class="type">CancellationToken</span> cancellationToken = <span class="keyword">default</span>
|
||
)
|
||
{
|
||
<span class="comment">// پیادهسازی</span>
|
||
}
|
||
</div>
|
||
|
||
<div class="warning-box">
|
||
<strong>⚠️ توجه مهم:</strong> از آنجایی که Policyهای ApiKey بر اساس Scope
|
||
تعریف شدهاند، نباید از Policyهای مبتنی بر Role مانند <code>XPolicies.User</code>
|
||
یا <code>XPolicies.EnabledUser</code> استفاده کنید. این Policyها انتظار Role Claim دارند
|
||
که API Key ندارد.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 5 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۵</div>
|
||
<div class="step-title">
|
||
تست و اعتبارسنجی
|
||
<span class="step-subtitle">Validation & Testing</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
پس از اعمال تغییرات، مسیر تست زیر را دنبال کنید:
|
||
</p>
|
||
|
||
<h4>گام ۵.۱ — ساخت یک API Key جدید</h4>
|
||
<div class="code-block" data-lang="HTTP">
|
||
<span class="comment"># Request: ساخت API Key جدید برای Application</span>
|
||
POST /Applications/{applicationId}/ApiKeys/Create
|
||
Content-Type: application/json
|
||
Authorization: Bearer {access_token}
|
||
|
||
{
|
||
"scopes": ["read", "write"],
|
||
"rateLimit": 60,
|
||
"expirationMinutes": 43200,
|
||
"allowedIPs": ["127.0.0.1"]
|
||
}
|
||
</div>
|
||
|
||
<p>
|
||
پاسخ باید شامل شیء <code>XApiKeyDto</code> باشد. نکته مهم: کلید اصلی (plainKey) فقط
|
||
<strong>یکبار</strong> در پاسخ اولیه برگردانده میشود و باید آن را در جای امن ذخیره کنید.
|
||
</p>
|
||
|
||
<h4>گام ۵.۲ — تست Endpoint با API Key</h4>
|
||
<div class="code-block" data-lang="HTTP">
|
||
<span class="comment"># Request: تست Endpoint محافظتشده با API Key</span>
|
||
GET /Account/Test/HiApiKeyAccess
|
||
X-Api-Key: xapp_xxxxxxxxxxxxxxxxxxxxxxxx
|
||
X-PoweredBy: {xPoweredValue}
|
||
</div>
|
||
|
||
<h4>گام ۵.۳ — بررسی نتیجه</h4>
|
||
|
||
<table class="styled-table">
|
||
<thead>
|
||
<tr>
|
||
<th>حالت</th>
|
||
<th>پاسخ مورد انتظار</th>
|
||
<th>تشخیص</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>API Key معتبر + Scope کافی</td>
|
||
<td><code>200 OK</code></td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>API Key نامعتبر</td>
|
||
<td><code>401 Unauthorized</code> با پیام «ApiKey not found»</td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>API Key منقضیشده</td>
|
||
<td><code>401</code> با پیام «ApiKey is Expired»</td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Scope ناکافی</td>
|
||
<td><code>403 Forbidden</code></td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>IP غیرمجاز</td>
|
||
<td><code>401</code> با پیام «Client IP not Allowed»</td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Rate Limit رد شده</td>
|
||
<td><code>401</code> با پیام «ApiKey Rate Limit Reached»</td>
|
||
<td><span class="badge badge-pass">✓ صحیح</span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STEP 6 -->
|
||
<div class="step-card">
|
||
<div class="step-header">
|
||
<div class="step-number">۶</div>
|
||
<div class="step-title">
|
||
بهروزرسانی Endpoint «Validate API Key»
|
||
<span class="step-subtitle">اصلاح کنترلر موجود</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-body">
|
||
<p>
|
||
در <code>ApplicationsController+Custom.cs</code>، متد <code>ValidateApiKey</code>
|
||
فعلی از <code>GetUserInfo()</code> استفاده میکند که نیازمند احراز هویت با
|
||
<code>Bearer Token</code> است. برای پیادهسازی درست، باید این Endpoint
|
||
<strong>خودش با API Key احراز هویت شود</strong> (سناریوی self-validation):
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// xIds/Controllers/Applications/ApplicationsController+Custom.cs</span>
|
||
|
||
<span class="comment">// نسخه اصلاحشده — با Policy API Key</span>
|
||
[<span class="type">HttpPost</span>(<span class="string">"ApiKeys/Validate"</span>)]
|
||
[<span class="type">AllowAnonymous</span>] <span class="comment">// ← چون خودش API Key را در Body میفرستد</span>
|
||
<span class="keyword">public async</span> <span class="type">Task</span><<span class="type">ActionResult</span><<span class="type">XApiKeyValidationResult</span>>> <span class="method">ValidateApiKey</span>(
|
||
[<span class="type">FromBody</span>] <span class="type">XApiKeyValidationRequest</span> request,
|
||
<span class="type">CancellationToken</span> cancellationToken = <span class="keyword">default</span>
|
||
)
|
||
{
|
||
<span class="keyword">try</span>
|
||
{
|
||
<span class="comment">// اعتبارسنجی ورودی</span>
|
||
<span class="keyword">await</span> ValidationProvider
|
||
.<span class="method">GroupValidationBuilder</span>()
|
||
.<span class="method">AddNotNull</span>(request)
|
||
.<span class="method">AddNotEmpty</span>(request.ApiKey)
|
||
.<span class="method">ValidateGroupAsync</span>();
|
||
|
||
<span class="comment">// استخراج ClientIP از Connection واقعی</span>
|
||
<span class="keyword">var</span> clientIP = HttpContext.Connection.RemoteIpAddress?.<span class="method">ToString</span>();
|
||
|
||
<span class="comment">// اعتبارسنجی مستقیم</span>
|
||
<span class="keyword">var</span> result = <span class="keyword">await</span> provider.<span class="method">ValidateApiKey</span>(
|
||
apiKey: request.ApiKey,
|
||
clientIP: clientIP,
|
||
requiredScope: request.RequiredScope,
|
||
userInfo: <span class="keyword">null</span>, <span class="comment">// ← userInfo دیگر نیاز نیست</span>
|
||
cancellationToken: cancellationToken
|
||
);
|
||
|
||
<span class="keyword">return</span> <span class="method">Ok</span>(result.<span class="method">ToDynamicObject</span>());
|
||
}
|
||
<span class="keyword">catch</span> (<span class="type">Exception</span> ex)
|
||
{
|
||
<span class="keyword">var</span> result = <span class="method">GetExceptionActionResult</span>(ex);
|
||
<span class="keyword">return</span> result;
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<strong>💡 چرا AllowAnonymous؟</strong> چون این Endpoint خودش نقش «اعتبارسنج API Key» را
|
||
دارد و نباید قبل از رسیدن به آن، احراز هویت شود. منطق اعتبارسنجی درون خودش انجام میشود.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 5: SUMMARY TABLE -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number">۵</div>
|
||
<div class="section-title">
|
||
<h3>خلاصه تغییرات</h3>
|
||
<div class="subtitle">Summary of Changes</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
<table class="styled-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>فایل</th>
|
||
<th>تغییر</th>
|
||
<th>اولویت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>۱</td>
|
||
<td><span class="file-ref">xIds/DI/XDIHelperExtension.cs</span></td>
|
||
<td>افزودن <code>ForwardDefaultSelector</code> برای انتخاب پویا بین JwtBearer و XApiKey</td>
|
||
<td><span class="badge badge-fail">حیاتی</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>۲</td>
|
||
<td><span class="file-ref">xIds/Providers/XApiKeyAuthenticationHandler.cs</span></td>
|
||
<td>حذف Scope ثابت «apikey» و افزودن Scopeهای واقعی از <code>validationResult.Scopes</code></td>
|
||
<td><span class="badge badge-fail">حیاتی</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>۳</td>
|
||
<td><span class="file-ref">xIds/Startup.cs</span></td>
|
||
<td>اطمینان از ترتیب صحیح ثبت Schemeها (نیاز به تغییر مستقیم ندارد اگر از Extension استفاده شود)</td>
|
||
<td><span class="badge badge-warn">متوسط</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>۴</td>
|
||
<td><span class="file-ref">xIds/Controllers/Applications/ApplicationsController+Custom.cs</span></td>
|
||
<td>اصلاح <code>ValidateApiKey</code> برای کارکرد مستقل</td>
|
||
<td><span class="badge badge-warn">متوسط</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td>۵</td>
|
||
<td><span class="file-ref">xIds/Controllers/Account/AccountController+Test.cs</span></td>
|
||
<td>افزودن تستهای احراز هویت با API Key (اختیاری، برای تست)</td>
|
||
<td><span class="badge badge-info">توصیهشده</span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="success-box">
|
||
<strong>✅ نتیجه نهایی:</strong> با اعمال گامهای ۱ و ۲ (که حیاتی هستند)، مشکل احراز هویت
|
||
با <code>XApiKey</code> بهطور کامل حل میشود. گامهای ۳ تا ۵ برای بهبود و تکمیل توصیه میشوند.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 6: BEST PRACTICES -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number">۶</div>
|
||
<div class="section-title">
|
||
<h3>بهترین شیوهها و توصیههای تکمیلی</h3>
|
||
<div class="subtitle">Best Practices & Additional Recommendations</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
|
||
<h4>۶.۱ — مدیریت صحیح Scopeها در سطح API Key</h4>
|
||
<p>
|
||
هنگام ساخت API Key، از مقادیر <code>XApiKeyScope</code> استفاده کنید:
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
<span class="comment">// نمونه ساخت API Key با Scopeهای صحیح</span>
|
||
<span class="keyword">var</span> result = <span class="keyword">await</span> provider.<span class="method">CreateApiKey</span>(
|
||
applicationId: applicationId,
|
||
scopes: <span class="keyword">new</span>[] { <span class="string">"read"</span>, <span class="string">"write"</span> }, <span class="comment">// ← مطابق XApiKeyScope</span>
|
||
allowedIPs: <span class="keyword">new</span>[] { <span class="string">"192.168.1.100"</span> },
|
||
rateLimit: 60,
|
||
expiration: <span class="type">TimeSpan</span>.<span class="method">FromDays</span>(30),
|
||
userInfo: userInfo
|
||
);
|
||
</div>
|
||
|
||
<h4>۶.۲ — تفکیک Policyهای مبتنی بر API Key از Policyهای مبتنی بر Role</h4>
|
||
<p>
|
||
Policyهای موجود در <code>XPolicies</code> دو دسته هستند:
|
||
</p>
|
||
<ul>
|
||
<li><strong>Policyهای مبتنی بر Scope</strong> (مثل <code>ApiKeyAccess</code>, <code>ReadAccess</code>) — برای API Key و Bearer Token</li>
|
||
<li><strong>Policyهای مبتنی بر Role</strong> (مثل <code>User</code>, <code>Admin</code>) — فقط برای Bearer Token</li>
|
||
</ul>
|
||
|
||
<div class="warning-box">
|
||
<strong>⚠️ توجه:</strong> هرگز از Policyهای Role-based برای Endpointهایی که قرار است با
|
||
API Key محافظت شوند استفاده نکنید. این Policyها انتظار Claim از نوع <code>role</code> دارند
|
||
که API Key ندارد.
|
||
</div>
|
||
|
||
<h4>۶.۳ — ذخیره امن Plain Key</h4>
|
||
<p>
|
||
کلید اصلی (plain key) فقط در لحظه ساخت برگردانده میشود. باید در جای امن ذخیره شود.
|
||
در صورتی که کاربر آن را گم کند، باید کلید قبلی revoke شده و کلید جدید ساخته شود.
|
||
</p>
|
||
|
||
<h4>۶.۴ — Rate Limit و Audit Log</h4>
|
||
<p>
|
||
در <code>XApiKeyConfiguration</code>، ویژگیهای زیر فعال هستند:
|
||
</p>
|
||
<ul>
|
||
<li><code>DefaultRateLimit</code> — محدودیت نرخ پیشفرض</li>
|
||
<li><code>EnableAuditLog</code> — برای ثبت استفاده از API Keyها</li>
|
||
</ul>
|
||
|
||
<p>
|
||
توصیه میشود در <code>ValidateApiKey</code> پس از اعتبارسنجی موفق، یک رکورد
|
||
<code>XApiKeyUsage</code> ثبت کنید تا تاریخچه استفاده قابل ردیابی باشد.
|
||
</p>
|
||
|
||
<h4>۶.۵ — Endpointهای توصیهشده برای تست</h4>
|
||
<p>
|
||
برای اطمینان از کارکرد صحیح، این Endpointها را در <code>AccountController+Test.cs</code>
|
||
اضافه کنید:
|
||
</p>
|
||
|
||
<div class="code-block" data-lang="C#">
|
||
[<span class="type">RequireXPowered</span>]
|
||
[<span class="type">HttpGet</span>(<span class="string">"Test/HiApiKeyReadAccess"</span>)]
|
||
[<span class="type">Authorize</span>(Policy = XPolicies.ApiKeyReadAccess)]
|
||
<span class="keyword">public</span> <span class="type">ActionResult</span><<span class="keyword">string</span>> <span class="method">HiApiKeyReadAccess</span>()
|
||
{
|
||
<span class="keyword">return</span> <span class="method">Ok</span>(<span class="string">"API Key Read Access Passed ..."</span>);
|
||
}
|
||
|
||
[<span class="type">RequireXPowered</span>]
|
||
[<span class="type">HttpPost</span>(<span class="string">"Test/HiApiKeyWriteAccess"</span>)]
|
||
[<span class="type">Authorize</span>(Policy = XPolicies.ApiKeyWriteAccess)]
|
||
<span class="keyword">public</span> <span class="type">ActionResult</span><<span class="keyword">string</span>> <span class="method">HiApiKeyWriteAccess</span>()
|
||
{
|
||
<span class="keyword">return</span> <span class="method">Ok</span>(<span class="string">"API Key Write Access Passed ..."</span>);
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<!-- SECTION 7: CONCLUSION -->
|
||
<!-- ══════════════════════════════════════════ -->
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<div class="section-number success">۷</div>
|
||
<div class="section-title">
|
||
<h3>جمعبندی</h3>
|
||
<div class="subtitle">Conclusion</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-content">
|
||
<p>
|
||
مشکل عدم احراز هویت با <code>XApiKey</code> در xIds ریشه در <strong>دو نقطه کد کلیدی</strong>
|
||
دارد که هر دو در این سند شناسایی و راهحل آنها ارائه شد:
|
||
</p>
|
||
|
||
<ol>
|
||
<li>
|
||
<strong>عدم انتخاب صحیح Scheme در Startup</strong> — که با افزودن
|
||
<code>ForwardDefaultSelector</code> در <code>AddJwtBearer</code> حل میشود.
|
||
</li>
|
||
<li>
|
||
<strong>عدم تطابق Claims تولیدشده با Policyها</strong> — که با افزودن Scopeهای
|
||
واقعی از <code>validationResult.Scopes</code> در <code>XApiKeyAuthenticationHandler</code>
|
||
حل میشود.
|
||
</li>
|
||
</ol>
|
||
|
||
<p>
|
||
با اعمال این دو تغییر ساده اما حیاتی، سیستم احراز هویت مبتنی بر API Key بهطور کامل فعال
|
||
میشود و Endpointهای محافظتشده با Policyهای <code>ApiKeyAccess</code>،
|
||
<code>ApiKeyReadAccess</code>، <code>ApiKeyWriteAccess</code> و
|
||
<code>ApiKeyAdminAccess</code> به درستی کار میکنند.
|
||
</p>
|
||
|
||
<div class="success-box">
|
||
<strong>✅ کلید موفقیت:</strong> زیرساخت موجود در xIds برای مدیریت API Key بسیار
|
||
کامل و حرفهای است. تنها دو نقطه اتصال کوچک در لایه احراز هویت نیاز به اصلاح داشتند
|
||
که در این سند بهطور کامل تشریح شدند.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-info">
|
||
این مستند فنی محرمانه بوده و صرفاً جهت استفاده تیم توسعه xSaherelmWorkspace تهیه شده است.
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ══════════ FOOTER ══════════ -->
|
||
<footer class="document-footer">
|
||
<div class="footer-brand">xSaherelmWorkspace — Technical Analysis</div>
|
||
<div class="footer-divider"></div>
|
||
<div>
|
||
تحلیل و مستندسازی: تیم فنی xSaherelmWorkspace | نسخه: ۱.۰ |
|
||
مهر ۱۴۰۵
|
||
</div>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
</body>
|
||
</html> |