fix some for iam
This commit is contained in:
@@ -8,7 +8,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
مستندها:
|
مستندها:
|
||||||
- `docs/iam-connection.md` — **نحوه ارتباط**: سه کانال داده و رفتار در خطا
|
- `docs/iam-connection.md` — **نحوه ارتباط**: سه کانال داده و رفتار در خطا
|
||||||
- `docs/iam-sso.md` — پیکربندی، ابطال نشست، محدودیتها
|
- `docs/iam-sso.md` — پیکربندی، ورود مشترک با همکار، ابطال نشست
|
||||||
- مرجع تصویری اندپوینتهای IAM: `<IAM>/docs` (Scalar)
|
- مرجع تصویری اندپوینتهای IAM: `<IAM>/docs` (Scalar)
|
||||||
|
|
||||||
## اصل غیرقابلمذاکره
|
## اصل غیرقابلمذاکره
|
||||||
@@ -16,10 +16,15 @@ alwaysApply: true
|
|||||||
این قابلیت **افزایشی** است. با `IAM_ENABLED=false` (پیشفرض) سامانه باید دقیقاً
|
این قابلیت **افزایشی** است. با `IAM_ENABLED=false` (پیشفرض) سامانه باید دقیقاً
|
||||||
مثل قبل رفتار کند: یک `bcrypt.compare` محلی و هیچ درخواست شبکهای.
|
مثل قبل رفتار کند: یک `bcrypt.compare` محلی و هیچ درخواست شبکهای.
|
||||||
|
|
||||||
- **هیچ تغییری در رابط کاربری نیست.** نه دکمه SSO، نه ریدایرکت، نه صفحه جدید.
|
- فرم ورود عوض نمیشود. پاسخ ورود همان `api_token` قبلی است، بهاضافهٔ
|
||||||
ادمین مثل همیشه موبایل و رمز را در همین پنل وارد میکند.
|
`iamBootstrapUrl` فقط وقتی IAM بلیت مرورگر داده باشد.
|
||||||
- پاسخ `POST /api/admin/auth/login` در هر دو حالت یکسان است (`api_token`,
|
- وقتی IAM روشن است و `IAM_ADMIN_REDIRECT_URI` (یا یکی از
|
||||||
`roles`, …) پس پنل ادمین تفاوتی نمیبیند.
|
`IAM_ADMIN_REDIRECT_URIS`) با آدرس خودِ پنل یکی است، بار اول بدون توکن یک
|
||||||
|
پرش `prompt=none` به `/auth/iam/callback` میرود. اپ کاربر همین کار را با
|
||||||
|
`IAM_REDIRECT_URI` و `IAM_REDIRECT_URIS` میکند. آدرس ناشناس جایگزین آدرس
|
||||||
|
ثبتشده نمیشود و پاسخ `enabled: false` میماند.
|
||||||
|
- IAM خاموش، یا redirect ادمین خالی، یعنی این پرش شروع نمیشود و خروج آدرس
|
||||||
|
مرکزی برنمیگرداند.
|
||||||
|
|
||||||
## نقشه فایلها
|
## نقشه فایلها
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ IAM_TENANT_SLUG=
|
|||||||
# off | fallback (default, safest) | exclusive (local password no longer accepted)
|
# off | fallback (default, safest) | exclusive (local password no longer accepted)
|
||||||
IAM_LOGIN_DELEGATION=fallback
|
IAM_LOGIN_DELEGATION=fallback
|
||||||
IAM_HTTP_TIMEOUT_MS=8000
|
IAM_HTTP_TIMEOUT_MS=8000
|
||||||
|
# صفحه callback اپ کاربر. باید در کلاینت IAM دقیقاً ثبت شود.
|
||||||
|
IAM_REDIRECT_URI=
|
||||||
|
# callbackهای اضافه (دولوپ و دامنه دیگر) با کاما. آدرس ناشناس جایگزین نمیشود.
|
||||||
|
IAM_REDIRECT_URIS=
|
||||||
|
# بعد از ورود محلی، مرورگر برای کوکی نشست مشترک به اینجا برمیگردد.
|
||||||
|
IAM_BROWSER_RETURN_URL=
|
||||||
|
# خروج سراسری فقط اگر این آدرس در post_logout کلاینت ثبت شده باشد برمیگردد.
|
||||||
|
IAM_POST_LOGOUT_REDIRECT_URI=
|
||||||
|
# مقصدهای مجاز خروج اپ کاربر، با کاما. باید عیناً در post_logout کلاینت باشند.
|
||||||
|
IAM_ALLOWED_RETURN_URLS=
|
||||||
|
# پنل ادمین. خالی یعنی بررسی بیصدای ورود در پنل انجام نمیشود.
|
||||||
|
IAM_ADMIN_REDIRECT_URI=
|
||||||
|
IAM_ADMIN_REDIRECT_URIS=
|
||||||
|
IAM_ADMIN_BROWSER_RETURN_URL=
|
||||||
|
IAM_ADMIN_POST_LOGOUT_REDIRECT_URI=
|
||||||
|
IAM_ADMIN_ALLOWED_RETURN_URLS=
|
||||||
|
|
||||||
# Revocable admin sessions (AdminSession table).
|
# Revocable admin sessions (AdminSession table).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'ک
|
|||||||
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
|
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
|
||||||
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
|
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
|
||||||
|
|
||||||
function onLogout() {
|
async function onLogout() {
|
||||||
auth.logout();
|
const left = await auth.logout();
|
||||||
router.push('/auth/login');
|
if (!left) router.push('/auth/login');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { adminRoute, fullPageAdminRoute } from './admin';
|
import { adminRoute, fullPageAdminRoute } from './admin';
|
||||||
import { branchRoute, fullPageBranchRoute } from './branch';
|
import { branchRoute, fullPageBranchRoute } from './branch';
|
||||||
import { isBranch, tokenName, userName } from '../utils';
|
import { iamAppUrl, isBranch, tokenName, userName } from '../utils';
|
||||||
import { useUsersStore } from '../stores/users';
|
import { useUsersStore } from '../stores/users';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
|
|
||||||
@@ -25,6 +25,12 @@ const router = createRouter({
|
|||||||
component: () => import('../layouts/full-page/FullPage.vue'),
|
component: () => import('../layouts/full-page/FullPage.vue'),
|
||||||
children: [
|
children: [
|
||||||
...(isBranch() ? fullPageBranchRoute : fullPageAdminRoute),
|
...(isBranch() ? fullPageBranchRoute : fullPageAdminRoute),
|
||||||
|
{
|
||||||
|
path: '/auth/iam/callback',
|
||||||
|
name: 'iam-callback',
|
||||||
|
component: () => import('../views/auth/IamCallback.vue'),
|
||||||
|
meta: { rule: 'editor' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/pages/error-404',
|
path: '/pages/error-404',
|
||||||
name: 'page-error-404',
|
name: 'page-error-404',
|
||||||
@@ -68,7 +74,7 @@ router.afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// legacy router.js:156-161
|
// legacy router.js:156-161
|
||||||
const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg'];
|
const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg', '/auth/iam/callback'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of legacy getPermission() (router.js:163-182).
|
* Port of legacy getPermission() (router.js:163-182).
|
||||||
@@ -109,6 +115,21 @@ async function getPermission(): Promise<string[] | undefined> {
|
|||||||
* equivalent and produces the same user-visible result.
|
* equivalent and produces the same user-visible result.
|
||||||
*/
|
*/
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
|
if (!localStorage.getItem(tokenName) && to.path !== '/auth/iam/callback' && sessionStorage.getItem('gb_admin_iam_sso_checked') !== '1') {
|
||||||
|
sessionStorage.setItem('gb_admin_iam_sso_checked', '1');
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('admin/auth/sso/start', {
|
||||||
|
params: { redirect_uri: iamAppUrl('auth/iam/callback') },
|
||||||
|
});
|
||||||
|
if (data?.enabled && data.authorize_url) {
|
||||||
|
window.location.assign(data.authorize_url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// سامانه هویت برای این پنل تنظیم نشده
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (noneAuthUrl.indexOf(to.path) > -1) {
|
if (noneAuthUrl.indexOf(to.path) > -1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Ported from legacy henza_admin/src/store/auth/moduleAuth.js
|
// Ported from legacy henza_admin/src/store/auth/moduleAuth.js
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { tokenName, userName } from '../utils';
|
import { iamAppUrl, tokenName, userName } from '../utils';
|
||||||
import { useUsersStore } from './users';
|
import { useUsersStore } from './users';
|
||||||
import type { UserInfo } from './users';
|
import type { UserInfo } from './users';
|
||||||
|
|
||||||
@@ -67,6 +67,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
delete persisted.ut;
|
delete persisted.ut;
|
||||||
|
|
||||||
this.inject(persisted);
|
this.inject(persisted);
|
||||||
|
if (data.iamBootstrapUrl) {
|
||||||
|
window.location.assign(data.iamBootstrapUrl);
|
||||||
|
}
|
||||||
return persisted;
|
return persisted;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,13 +86,28 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
* its module-scoped `roles` survived a session change (bug #2). Clearing
|
* its module-scoped `roles` survived a session change (bug #2). Clearing
|
||||||
* both storage and the roles store is the fix for that.
|
* both storage and the roles store is the fix for that.
|
||||||
*/
|
*/
|
||||||
logout() {
|
async logout(): Promise<boolean> {
|
||||||
|
let central: string | null = null;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('admin/auth/sso/logout-url', {
|
||||||
|
params: { return_url: iamAppUrl('auth/login') },
|
||||||
|
});
|
||||||
|
central = data?.logout_url ?? null;
|
||||||
|
} catch {
|
||||||
|
central = null;
|
||||||
|
}
|
||||||
localStorage.removeItem(tokenName);
|
localStorage.removeItem(tokenName);
|
||||||
localStorage.removeItem(userName);
|
localStorage.removeItem(userName);
|
||||||
localStorage.removeItem('login');
|
localStorage.removeItem('login');
|
||||||
|
sessionStorage.removeItem('gb_admin_iam_sso_checked');
|
||||||
const users = useUsersStore();
|
const users = useUsersStore();
|
||||||
users.deleteRoles();
|
users.deleteRoles();
|
||||||
users.deleteUserInfo();
|
users.deleteUserInfo();
|
||||||
|
if (central) {
|
||||||
|
window.location.assign(central);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,5 +15,12 @@ export function isBranch(): boolean {
|
|||||||
|
|
||||||
// Legacy stored the token under a different localStorage key per mode.
|
// Legacy stored the token under a different localStorage key per mode.
|
||||||
// See henza_admin/src/axios.js:6 and router.js:173-174,188.
|
// See henza_admin/src/axios.js:6 and router.js:173-174,188.
|
||||||
|
/** آدرس همین پنل، با پیشوند /branch در حالت شعبه. */
|
||||||
|
export function iamAppUrl(path: string) {
|
||||||
|
const base = import.meta.env.BASE_URL || '/';
|
||||||
|
const prefix = base.endsWith('/') ? base : `${base}/`;
|
||||||
|
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const tokenName = isBranch() ? 'branch_token' : 'token';
|
export const tokenName = isBranch() ? 'branch_token' : 'token';
|
||||||
export const userName = isBranch() ? 'branch_user' : 'user';
|
export const userName = isBranch() ? 'branch_user' : 'user';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import api from '../../api/client';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
sessionStorage.setItem('gb_admin_iam_sso_checked', '1');
|
||||||
|
|
||||||
|
if (params.get('error') || !params.get('code') || !params.get('state')) {
|
||||||
|
await router.replace('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('admin/auth/sso/callback', {
|
||||||
|
code: params.get('code'),
|
||||||
|
state: params.get('state'),
|
||||||
|
});
|
||||||
|
const persisted = { ...data };
|
||||||
|
delete persisted.roles;
|
||||||
|
delete persisted.ut;
|
||||||
|
auth.inject(persisted);
|
||||||
|
await router.replace('/dashboard/ecommerce');
|
||||||
|
} catch {
|
||||||
|
await router.replace('/auth/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="min-height: 100vh; display: grid; place-items: center">در حال ورود...</div>
|
||||||
|
</template>
|
||||||
@@ -26,3 +26,21 @@ export function envOrNull(name: string): string | null {
|
|||||||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||||
return trimmed === '' ? null : trimmed;
|
return trimmed === '' ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Comma-separated env list. Blank entries are dropped. */
|
||||||
|
export function envList(name: string): string[] {
|
||||||
|
const raw = envOrNull(name);
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact string match. A requested URI that is not in the list is refused
|
||||||
|
* (null) instead of being replaced by another app's address.
|
||||||
|
* With no request, the first configured value is used.
|
||||||
|
*/
|
||||||
|
export function pickExactUri(requested: string | undefined, allowed: Array<string | null>): string | null {
|
||||||
|
const unique = [...new Set(allowed.filter((item): item is string => Boolean(item)))];
|
||||||
|
if (requested) return unique.includes(requested) ? requested : null;
|
||||||
|
return unique[0] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { CurrentAdmin, Public } from './guards';
|
import { CurrentAdmin, Public } from './guards';
|
||||||
@@ -26,9 +27,53 @@ export class AuthController {
|
|||||||
return this.authService.login(dto, ip, userAgent);
|
return this.authService.login(dto, ip, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('sso/start')
|
||||||
|
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const started = this.authService.beginBrowserSso(redirectUri);
|
||||||
|
if (!started) return { enabled: false, authorize_url: null };
|
||||||
|
res.setHeader('Set-Cookie', started.cookie);
|
||||||
|
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('sso/callback')
|
||||||
|
@HttpCode(200)
|
||||||
|
ssoCallback(
|
||||||
|
@Body() body: { code?: string; state?: string },
|
||||||
|
@Headers('cookie') cookieHeader: string | undefined,
|
||||||
|
@Ip() ip: string,
|
||||||
|
@Headers('user-agent') userAgent: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||||
|
return this.authService.completeBrowserSso(
|
||||||
|
readCookie(cookieHeader, 'gb_admin_iam_sso'),
|
||||||
|
body.code ?? '',
|
||||||
|
body.state ?? '',
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('sso/logout-url')
|
||||||
|
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||||
|
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||||
return this.authService.showData(admin.id);
|
return this.authService.showData(admin.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCookie(header: string | undefined, name: string): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key === name) return decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
type IamIdentity,
|
type IamIdentity,
|
||||||
} from '../iam/iam.service';
|
} from '../iam/iam.service';
|
||||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||||
|
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||||
import type { LoginDto } from './dto/login.dto';
|
import type { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -73,6 +75,130 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.fullName,
|
||||||
|
fullName: user.fullName,
|
||||||
|
mobile: user.mobile,
|
||||||
|
role: user.role,
|
||||||
|
ut: user.userTypeId,
|
||||||
|
branch_id: user.branchId,
|
||||||
|
api_token: apiToken,
|
||||||
|
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
|
||||||
|
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||||
|
if (!this.iam.enabled) return null;
|
||||||
|
const redirectUri = pickExactUri(requestedRedirect, [
|
||||||
|
envOrNull('IAM_ADMIN_REDIRECT_URI'),
|
||||||
|
...envList('IAM_ADMIN_REDIRECT_URIS'),
|
||||||
|
]);
|
||||||
|
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||||
|
if (!redirectUri || !clientId) return null;
|
||||||
|
|
||||||
|
const state = randomBytes(24).toString('base64url');
|
||||||
|
const nonce = randomBytes(24).toString('base64url');
|
||||||
|
const verifier = randomBytes(48).toString('base64url');
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||||
|
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||||
|
const secure = redirectUri.startsWith('https:');
|
||||||
|
const cookie =
|
||||||
|
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||||
|
(secure ? '; Secure' : '');
|
||||||
|
|
||||||
|
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
url.searchParams.set('scope', 'openid profile email');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('nonce', nonce);
|
||||||
|
url.searchParams.set('code_challenge', challenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
url.searchParams.set('prompt', 'none');
|
||||||
|
return { authorizeUrl: url.toString(), cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
|
||||||
|
const pending = this.readSsoCookie(cookieValue);
|
||||||
|
if (!pending || pending.state !== state) {
|
||||||
|
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||||
|
if (!claims.phoneNumber) {
|
||||||
|
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.adminUser.findFirst({
|
||||||
|
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||||
|
include: { userType: { include: { permissions: true } } },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity: IamIdentity = {
|
||||||
|
sub: claims.sub,
|
||||||
|
email: null,
|
||||||
|
phoneNumber: claims.phoneNumber,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
sessionId: claims.sessionId,
|
||||||
|
browserTicket: null,
|
||||||
|
};
|
||||||
|
return this.issueAdminToken(user, identity, ip, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||||
|
if (!this.iam.enabled) return null;
|
||||||
|
const redirect = pickExactUri(requestedReturn, [
|
||||||
|
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
|
||||||
|
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
|
||||||
|
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
|
||||||
|
]);
|
||||||
|
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||||
|
if (!redirect || !clientId) return null;
|
||||||
|
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueAdminToken(
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
mobile: string;
|
||||||
|
role: string;
|
||||||
|
userTypeId: number | null;
|
||||||
|
branchId: string | null;
|
||||||
|
userType: { permissions: { rid: string }[] } | null;
|
||||||
|
},
|
||||||
|
identity: IamIdentity | undefined,
|
||||||
|
ip?: string,
|
||||||
|
userAgent?: string,
|
||||||
|
) {
|
||||||
|
const jti = this.sessions.newTokenId();
|
||||||
|
const apiToken = await this.jwt.signAsync({
|
||||||
|
sub: user.id,
|
||||||
|
mobile: user.mobile,
|
||||||
|
...(jti ? { jti } : {}),
|
||||||
|
});
|
||||||
|
if (jti) {
|
||||||
|
await this.sessions.record({
|
||||||
|
jti,
|
||||||
|
adminUserId: user.id,
|
||||||
|
iamSessionId: identity?.sessionId ?? null,
|
||||||
|
iamSub: identity?.sub ?? null,
|
||||||
|
ipAddress: ip ?? null,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.fullName,
|
name: user.fullName,
|
||||||
@@ -86,6 +212,27 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ssoSecret(): string {
|
||||||
|
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
|
||||||
|
if (!value || !value.includes('.')) return null;
|
||||||
|
const split = value.lastIndexOf('.');
|
||||||
|
const payload = value.slice(0, split);
|
||||||
|
const sig = value.slice(split + 1);
|
||||||
|
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||||
|
if (expected.length !== sig.length) return null;
|
||||||
|
let same = 0;
|
||||||
|
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||||
|
if (same !== 0) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Password check, optionally delegated to the central identity service.
|
* Password check, optionally delegated to the central identity service.
|
||||||
*
|
*
|
||||||
@@ -168,3 +315,11 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobileVariants(raw: string): string[] {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const variants = new Set<string>([raw.trim(), digits]);
|
||||||
|
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||||
|
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||||
|
return [...variants].filter((value) => value !== '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Ip, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { CustomerAuthService } from './customer-auth.service';
|
import { CustomerAuthService } from './customer-auth.service';
|
||||||
import { CustomersService } from '../customers/customers.service';
|
import { CustomersService } from '../customers/customers.service';
|
||||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||||
@@ -43,6 +44,35 @@ export class CustomerAuthController {
|
|||||||
return this.auth.verifyOtp(dto.username, dto.password, ip);
|
return this.auth.verifyOtp(dto.username, dto.password, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** اگر نشست IAM در مرورگر باشد، آدرس authorize با prompt=none برمیگردد. */
|
||||||
|
@Public()
|
||||||
|
@Get('auth/sso/start')
|
||||||
|
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const started = this.auth.beginBrowserSso(redirectUri);
|
||||||
|
if (!started) return { enabled: false, authorize_url: null };
|
||||||
|
res.setHeader('Set-Cookie', started.cookie);
|
||||||
|
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('auth/sso/callback')
|
||||||
|
@HttpCode(200)
|
||||||
|
ssoCallback(
|
||||||
|
@Body() body: { code?: string; state?: string },
|
||||||
|
@Headers('cookie') cookieHeader: string | undefined,
|
||||||
|
@Ip() ip: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
res.setHeader('Set-Cookie', 'gb_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||||
|
return this.auth.completeBrowserSso(readCookie(cookieHeader, 'gb_iam_sso'), body.code ?? '', body.state ?? '', ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('auth/sso/logout-url')
|
||||||
|
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||||
|
return { logout_url: this.auth.centralLogoutUrl(returnUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
@CustomerAuth()
|
@CustomerAuth()
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@CurrentCustomer() me: AuthenticatedCustomer) {
|
me(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||||
@@ -61,3 +91,12 @@ export class CustomerAuthController {
|
|||||||
return this.customers.getLedgerEntries(me.id);
|
return this.customers.getLedgerEntries(me.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCookie(header: string | undefined, name: string): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key === name) return decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||||
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
type IamIdentity,
|
type IamIdentity,
|
||||||
} from '../iam/iam.service';
|
} from '../iam/iam.service';
|
||||||
import { AppSessionService } from '../sessions/app-session.service';
|
import { AppSessionService } from '../sessions/app-session.service';
|
||||||
import { envOr, envOrNull } from '../../common/env';
|
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||||
import { faToEn } from './digits';
|
import { faToEn } from './digits';
|
||||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||||
@@ -61,6 +62,14 @@ interface IssueContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** شماره ماسکشده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
|
/** شماره ماسکشده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
|
||||||
|
function mobileVariants(raw: string): string[] {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const variants = new Set<string>([raw.trim(), digits]);
|
||||||
|
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||||
|
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||||
|
return [...variants].filter((value) => value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
function maskMobile(mobile: string): string | null {
|
function maskMobile(mobile: string): string | null {
|
||||||
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
|
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
|
||||||
}
|
}
|
||||||
@@ -387,16 +396,22 @@ export class CustomerAuthService {
|
|||||||
|
|
||||||
const expert = await this.resolveExpertPrincipal(mobile);
|
const expert = await this.resolveExpertPrincipal(mobile);
|
||||||
if (expert) {
|
if (expert) {
|
||||||
return {
|
return this.withBootstrap(
|
||||||
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
|
{
|
||||||
customer: { id: expert.tradingCustomerId, mobile },
|
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
|
||||||
};
|
customer: { id: expert.tradingCustomerId, mobile },
|
||||||
|
},
|
||||||
|
identity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return this.withBootstrap(
|
||||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
|
{
|
||||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
|
||||||
};
|
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||||
|
},
|
||||||
|
identity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -478,6 +493,140 @@ export class CustomerAuthService {
|
|||||||
* Reference reused api_token forever once set (:60-62); a fresh token is
|
* Reference reused api_token forever once set (:60-62); a fresh token is
|
||||||
* issued on every verify here (T1).
|
* issued on every verify here (T1).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* شروع بررسی بیصدای نشست IAM. کوکی امضاشده code_verifier را نگه میدارد
|
||||||
|
* تا در آدرس بازگشت دیده نشود.
|
||||||
|
*/
|
||||||
|
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||||
|
if (!this.iam.enabled) return null;
|
||||||
|
const redirectUri = pickExactUri(requestedRedirect, [
|
||||||
|
envOrNull('IAM_REDIRECT_URI'),
|
||||||
|
...envList('IAM_REDIRECT_URIS'),
|
||||||
|
]);
|
||||||
|
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||||
|
if (!redirectUri || !clientId) return null;
|
||||||
|
|
||||||
|
const state = randomBytes(24).toString('base64url');
|
||||||
|
const nonce = randomBytes(24).toString('base64url');
|
||||||
|
const verifier = randomBytes(48).toString('base64url');
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||||
|
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||||
|
const secure = redirectUri.startsWith('https:');
|
||||||
|
const cookie =
|
||||||
|
`gb_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||||
|
(secure ? '; Secure' : '');
|
||||||
|
|
||||||
|
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
url.searchParams.set('scope', 'openid profile email');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('nonce', nonce);
|
||||||
|
url.searchParams.set('code_challenge', challenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
url.searchParams.set('prompt', 'none');
|
||||||
|
|
||||||
|
return { authorizeUrl: url.toString(), cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip: string | undefined) {
|
||||||
|
const pending = this.readSsoCookie(cookieValue);
|
||||||
|
if (!pending || pending.state !== state) {
|
||||||
|
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||||
|
if (!claims.phoneNumber || !claims.sub) {
|
||||||
|
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobiles = mobileVariants(claims.phoneNumber);
|
||||||
|
let expert: { adminUserId: string; tradingCustomerId: string } | null = null;
|
||||||
|
let expertMobile = claims.phoneNumber;
|
||||||
|
for (const candidate of mobiles) {
|
||||||
|
expert = await this.resolveExpertPrincipal(candidate);
|
||||||
|
if (expert) {
|
||||||
|
expertMobile = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identity: IamIdentity = {
|
||||||
|
sub: claims.sub,
|
||||||
|
email: null,
|
||||||
|
phoneNumber: claims.phoneNumber,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
sessionId: claims.sessionId,
|
||||||
|
browserTicket: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (expert) {
|
||||||
|
return {
|
||||||
|
token: await this.issueExpertToken(expert.adminUserId, expertMobile, { identity, ip }),
|
||||||
|
customer: { id: expert.tradingCustomerId, mobile: expertMobile },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await this.prisma.customer.findFirst({
|
||||||
|
where: { mobile: { in: mobiles }, isActive: true, isArchived: false },
|
||||||
|
select: { id: true, mobile: true },
|
||||||
|
});
|
||||||
|
if (!customer) {
|
||||||
|
throw new UnauthorizedException('حسابی با این شماره در این سامانه نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: await this.issueToken(customer.id, customer.mobile, { identity, ip }),
|
||||||
|
customer: { id: customer.id, mobile: customer.mobile },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||||
|
if (!this.iam.enabled) return null;
|
||||||
|
const redirect = pickExactUri(requestedReturn, [
|
||||||
|
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI'),
|
||||||
|
envOrNull('IAM_BROWSER_RETURN_URL'),
|
||||||
|
...envList('IAM_ALLOWED_RETURN_URLS'),
|
||||||
|
]);
|
||||||
|
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||||
|
if (!redirect || !clientId) return null;
|
||||||
|
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private withBootstrap<T extends { token: string; customer: { id: string; mobile: string } }>(
|
||||||
|
body: T,
|
||||||
|
identity: IamIdentity | null,
|
||||||
|
): T & { iamBootstrapUrl?: string } {
|
||||||
|
const url = this.iam.browserBootstrapUrl(identity?.browserTicket);
|
||||||
|
return url ? { ...body, iamBootstrapUrl: url } : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ssoSecret(): string {
|
||||||
|
return envOrNull('JWT_CUSTOMER_SECRET') || envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSsoCookie(value: string | null): { state: string; nonce: string; verifier: string; redirectUri: string } | null {
|
||||||
|
if (!value || !value.includes('.')) return null;
|
||||||
|
const split = value.lastIndexOf('.');
|
||||||
|
const payload = value.slice(0, split);
|
||||||
|
const sig = value.slice(split + 1);
|
||||||
|
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||||
|
if (expected.length !== sig.length) return null;
|
||||||
|
let same = 0;
|
||||||
|
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||||
|
if (same !== 0) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async issueToken(
|
private async issueToken(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
mobile: string,
|
mobile: string,
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ describe('IamService', () => {
|
|||||||
firstName: 'رضا',
|
firstName: 'رضا',
|
||||||
lastName: 'کاظمی',
|
lastName: 'کاظمی',
|
||||||
sessionId: 'sid-9',
|
sessionId: 'sid-9',
|
||||||
|
browserTicket: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface IamIdentity {
|
|||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
/** بلیت یکبارمصرف برای گذاشتن کوکی نشست روی دامنه IAM */
|
||||||
|
browserTicket: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The identity service rejected the credentials (unknown user or wrong password). */
|
/** The identity service rejected the credentials (unknown user or wrong password). */
|
||||||
@@ -113,6 +115,8 @@ export class IamService {
|
|||||||
if (tenantId) body.tenant_id = tenantId;
|
if (tenantId) body.tenant_id = tenantId;
|
||||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||||
if (clientIp) body.client_ip = clientIp;
|
if (clientIp) body.client_ip = clientIp;
|
||||||
|
const returnTo = envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') || envOrNull('IAM_BROWSER_RETURN_URL');
|
||||||
|
if (returnTo) body.return_to = returnTo;
|
||||||
|
|
||||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||||
|
|
||||||
@@ -148,6 +152,7 @@ export class IamService {
|
|||||||
firstName: user.first_name ?? null,
|
firstName: user.first_name ?? null,
|
||||||
lastName: user.last_name ?? null,
|
lastName: user.last_name ?? null,
|
||||||
sessionId: payload.session_id ?? null,
|
sessionId: payload.session_id ?? null,
|
||||||
|
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +182,8 @@ export class IamService {
|
|||||||
* and no way for a central sign-out to end it.
|
* and no way for a central sign-out to end it.
|
||||||
*
|
*
|
||||||
* The IAM only accepts this from a trusted confidential client carrying the
|
* The IAM only accepts this from a trusted confidential client carrying the
|
||||||
* explicit `passwordless_delegation_allowed` flag, and it never creates an
|
* explicit `passwordless_delegation_allowed` flag. An unknown mobile is
|
||||||
* account: an unknown user is reported as such so this app can carry on with
|
* created there. A locked or inactive account is still refused.
|
||||||
* its own local login.
|
|
||||||
*/
|
*/
|
||||||
async attestOtpLogin(
|
async attestOtpLogin(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
@@ -199,6 +203,8 @@ export class IamService {
|
|||||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||||
if (clientIp) body.client_ip = clientIp;
|
if (clientIp) body.client_ip = clientIp;
|
||||||
if (factorReference) body.factor_reference = factorReference;
|
if (factorReference) body.factor_reference = factorReference;
|
||||||
|
const returnTo = envOrNull('IAM_BROWSER_RETURN_URL');
|
||||||
|
if (returnTo) body.return_to = returnTo;
|
||||||
|
|
||||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||||
|
|
||||||
@@ -228,6 +234,7 @@ export class IamService {
|
|||||||
firstName: user.first_name ?? null,
|
firstName: user.first_name ?? null,
|
||||||
lastName: user.last_name ?? null,
|
lastName: user.last_name ?? null,
|
||||||
sessionId: payload.session_id ?? null,
|
sessionId: payload.session_id ?? null,
|
||||||
|
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,4 +253,65 @@ export class IamService {
|
|||||||
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
|
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
|
||||||
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
browserBootstrapUrl(ticket: string | null | undefined): string | null {
|
||||||
|
if (!ticket || !this.issuer) return null;
|
||||||
|
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تبادل کد مجوز مرورگر. پاسخ از خود issuer و با راز کلاینت آمده است. */
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<{ sub: string; phoneNumber: string | null; sessionId: string | null }> {
|
||||||
|
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
client_id: this.clientId as string,
|
||||||
|
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${this.issuer}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
|
||||||
|
if (!response.ok || typeof payload.id_token !== 'string') {
|
||||||
|
throw new IamUnavailableError(`identity service refused the authorization code (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = decodeIdToken(payload.id_token);
|
||||||
|
if (claims.iss !== this.issuer) {
|
||||||
|
throw new IamUnavailableError('identity token issuer does not match');
|
||||||
|
}
|
||||||
|
const aud = claims.aud;
|
||||||
|
const audiences = Array.isArray(aud) ? aud : [aud];
|
||||||
|
if (!audiences.includes(this.clientId)) {
|
||||||
|
throw new IamUnavailableError('identity token audience does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: String(claims.sub ?? ''),
|
||||||
|
phoneNumber: typeof claims.phone_number === 'string' ? claims.phone_number : null,
|
||||||
|
sessionId: typeof claims.sid === 'string' ? claims.sid : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIdToken(token: string): Record<string, any> {
|
||||||
|
const part = token.split('.')[1];
|
||||||
|
if (!part) throw new IamUnavailableError('identity token is malformed');
|
||||||
|
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,12 @@ export interface ReportBalance {
|
|||||||
arz: string;
|
arz: string;
|
||||||
mandeh: string;
|
mandeh: string;
|
||||||
}
|
}
|
||||||
|
function iamAppUrl(path: string) {
|
||||||
|
const base = import.meta.env.BASE_URL || '/'
|
||||||
|
const prefix = base.endsWith('/') ? base : `${base}/`
|
||||||
|
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||||
/**
|
/**
|
||||||
@@ -298,11 +304,27 @@ export const api = {
|
|||||||
* already-used code is a 401.
|
* already-used code is a 401.
|
||||||
*/
|
*/
|
||||||
verifyOtp: (username: string, password: string) =>
|
verifyOtp: (username: string, password: string) =>
|
||||||
request<{ token: string; customer: { id: string; mobile: string } }>(
|
request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>(
|
||||||
'/customer/auth/verify-otp',
|
'/customer/auth/verify-otp',
|
||||||
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
ssoStart: () =>
|
||||||
|
request<{ enabled: boolean; authorize_url: string | null }>(
|
||||||
|
`/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
ssoCallback: (code: string, state: string) =>
|
||||||
|
request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
ssoLogoutUrl: () =>
|
||||||
|
request<{ logout_url: string | null }>(
|
||||||
|
`/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`,
|
||||||
|
),
|
||||||
|
|
||||||
// The three reads below identify the customer by TOKEN. They replaced
|
// The three reads below identify the customer by TOKEN. They replaced
|
||||||
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
||||||
// the URL and let anyone read anyone (SEC-08).
|
// the URL and let anyone read anyone (SEC-08).
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const router = createRouter({
|
|||||||
{ path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } },
|
{ path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } },
|
||||||
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
|
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
|
||||||
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } },
|
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } },
|
||||||
|
{ path: '/auth/iam/callback', name: 'iamCallback', component: () => import('../views/user/IamCallback.vue'), meta: { layout: 'FullPage' } },
|
||||||
{ path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') },
|
{ path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') },
|
||||||
{ path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
|
{ path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
|
||||||
{
|
{
|
||||||
@@ -96,6 +97,19 @@ router.beforeEach(async (to) => {
|
|||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const nav = useNavStore();
|
const nav = useNavStore();
|
||||||
|
|
||||||
|
if (!session.isLoggedIn && to.name !== 'iamCallback' && sessionStorage.getItem('gb_iam_sso_checked') !== '1') {
|
||||||
|
sessionStorage.setItem('gb_iam_sso_checked', '1');
|
||||||
|
try {
|
||||||
|
const started = await api.ssoStart();
|
||||||
|
if (started.enabled && started.authorize_url) {
|
||||||
|
window.location.assign(started.authorize_url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// سامانه هویت خاموش است یا در دسترس نیست
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'login' && session.isLoggedIn) {
|
if (to.name === 'login' && session.isLoggedIn) {
|
||||||
return { name: 'home' };
|
return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,9 +283,20 @@ function goToDetails(id: number) {
|
|||||||
if (id === 6) router.push({ name: 'report' })
|
if (id === 6) router.push({ name: 'report' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function logOut() {
|
async function logOut() {
|
||||||
session.logout()
|
|
||||||
data.notify = false
|
data.notify = false
|
||||||
|
let central: string | null = null
|
||||||
|
try {
|
||||||
|
central = (await api.ssoLogoutUrl()).logout_url
|
||||||
|
} catch {
|
||||||
|
central = null
|
||||||
|
}
|
||||||
|
session.logout()
|
||||||
|
sessionStorage.removeItem('gb_iam_sso_checked')
|
||||||
|
if (central) {
|
||||||
|
window.location.assign(central)
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push({ name: 'home' })
|
router.push({ name: 'home' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
import { useSessionStore } from '../../stores/session'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
sessionStorage.setItem('gb_iam_sso_checked', '1')
|
||||||
|
|
||||||
|
if (params.get('error') || !params.get('code') || !params.get('state')) {
|
||||||
|
await router.replace({ name: 'login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.ssoCallback(params.get('code') as string, params.get('state') as string)
|
||||||
|
session.login(result.token, result.customer.id, result.customer.mobile)
|
||||||
|
try {
|
||||||
|
const who = await api.getExpertIdentity()
|
||||||
|
session.setSalesExpert(who.isSalesExpert)
|
||||||
|
} catch {
|
||||||
|
session.setSalesExpert(false)
|
||||||
|
}
|
||||||
|
await router.replace({ name: 'home' })
|
||||||
|
} catch {
|
||||||
|
await router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="min-height: 100vh; display: grid; place-items: center">در حال ورود...</div>
|
||||||
|
</template>
|
||||||
@@ -159,7 +159,8 @@ const sendPhone = async () => {
|
|||||||
const sendToken = async (otpCode: string) => {
|
const sendToken = async (otpCode: string) => {
|
||||||
try {
|
try {
|
||||||
body.loading = true
|
body.loading = true
|
||||||
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
||||||
|
const { token, customer } = verified
|
||||||
session.login(token, customer.id, customer.mobile)
|
session.login(token, customer.id, customer.mobile)
|
||||||
// D21 — ask the server which kind of principal this token is, so the
|
// D21 — ask the server which kind of principal this token is, so the
|
||||||
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
||||||
@@ -171,6 +172,10 @@ const sendToken = async (otpCode: string) => {
|
|||||||
} catch {
|
} catch {
|
||||||
session.setSalesExpert(false)
|
session.setSalesExpert(false)
|
||||||
}
|
}
|
||||||
|
if (verified.iamBootstrapUrl) {
|
||||||
|
window.location.assign(verified.iamBootstrapUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
||||||
await router.push(redirect ?? { name: 'home' })
|
await router.push(redirect ?? { name: 'home' })
|
||||||
toast.success('با موفقیت وارد شدید')
|
toast.success('با موفقیت وارد شدید')
|
||||||
|
|||||||
@@ -294,6 +294,12 @@ export interface ReportBalance {
|
|||||||
arz: string;
|
arz: string;
|
||||||
mandeh: string;
|
mandeh: string;
|
||||||
}
|
}
|
||||||
|
function iamAppUrl(path: string) {
|
||||||
|
const base = import.meta.env.BASE_URL || '/'
|
||||||
|
const prefix = base.endsWith('/') ? base : `${base}/`
|
||||||
|
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||||
|
|
||||||
@@ -314,11 +320,27 @@ export const api = {
|
|||||||
* already-used code is a 401.
|
* already-used code is a 401.
|
||||||
*/
|
*/
|
||||||
verifyOtp: (username: string, password: string) =>
|
verifyOtp: (username: string, password: string) =>
|
||||||
request<{ token: string; customer: { id: string; mobile: string } }>(
|
request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>(
|
||||||
'/customer/auth/verify-otp',
|
'/customer/auth/verify-otp',
|
||||||
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
ssoStart: () =>
|
||||||
|
request<{ enabled: boolean; authorize_url: string | null }>(
|
||||||
|
`/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
ssoCallback: (code: string, state: string) =>
|
||||||
|
request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
ssoLogoutUrl: () =>
|
||||||
|
request<{ logout_url: string | null }>(
|
||||||
|
`/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`,
|
||||||
|
),
|
||||||
|
|
||||||
// The three reads below identify the customer by TOKEN. They replaced
|
// The three reads below identify the customer by TOKEN. They replaced
|
||||||
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
||||||
// the URL and let anyone read anyone (SEC-08).
|
// the URL and let anyone read anyone (SEC-08).
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const router = createRouter({
|
|||||||
{ path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } },
|
{ path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } },
|
||||||
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
|
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
|
||||||
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } },
|
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } },
|
||||||
|
{ path: '/auth/iam/callback', name: 'iamCallback', component: () => import('../views/user/IamCallback.vue'), meta: { layout: 'FullPage' } },
|
||||||
{ path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') },
|
{ path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') },
|
||||||
{ path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
|
{ path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
|
||||||
{
|
{
|
||||||
@@ -101,6 +102,19 @@ router.beforeEach(async (to) => {
|
|||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const nav = useNavStore();
|
const nav = useNavStore();
|
||||||
|
|
||||||
|
if (!session.isLoggedIn && to.name !== 'iamCallback' && sessionStorage.getItem('gb_iam_sso_checked') !== '1') {
|
||||||
|
sessionStorage.setItem('gb_iam_sso_checked', '1');
|
||||||
|
try {
|
||||||
|
const started = await api.ssoStart();
|
||||||
|
if (started.enabled && started.authorize_url) {
|
||||||
|
window.location.assign(started.authorize_url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// سامانه هویت خاموش است یا در دسترس نیست
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'login' && session.isLoggedIn) {
|
if (to.name === 'login' && session.isLoggedIn) {
|
||||||
return { name: 'home' };
|
return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,9 +237,20 @@ function goToDetails(id: number) {
|
|||||||
if (id === 6) router.push({ name: 'report' })
|
if (id === 6) router.push({ name: 'report' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function logOut() {
|
async function logOut() {
|
||||||
session.logout()
|
|
||||||
data.notify = false
|
data.notify = false
|
||||||
|
let central: string | null = null
|
||||||
|
try {
|
||||||
|
central = (await api.ssoLogoutUrl()).logout_url
|
||||||
|
} catch {
|
||||||
|
central = null
|
||||||
|
}
|
||||||
|
session.logout()
|
||||||
|
sessionStorage.removeItem('gb_iam_sso_checked')
|
||||||
|
if (central) {
|
||||||
|
window.location.assign(central)
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push({ name: 'home' })
|
router.push({ name: 'home' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
import { useSessionStore } from '../../stores/session'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
sessionStorage.setItem('gb_iam_sso_checked', '1')
|
||||||
|
|
||||||
|
if (params.get('error') || !params.get('code') || !params.get('state')) {
|
||||||
|
await router.replace({ name: 'login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.ssoCallback(params.get('code') as string, params.get('state') as string)
|
||||||
|
session.login(result.token, result.customer.id, result.customer.mobile)
|
||||||
|
try {
|
||||||
|
const who = await api.getExpertIdentity()
|
||||||
|
session.setSalesExpert(who.isSalesExpert)
|
||||||
|
} catch {
|
||||||
|
session.setSalesExpert(false)
|
||||||
|
}
|
||||||
|
await router.replace({ name: 'home' })
|
||||||
|
} catch {
|
||||||
|
await router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="min-height: 100vh; display: grid; place-items: center">در حال ورود...</div>
|
||||||
|
</template>
|
||||||
@@ -159,7 +159,8 @@ const sendPhone = async () => {
|
|||||||
const sendToken = async (otpCode: string) => {
|
const sendToken = async (otpCode: string) => {
|
||||||
try {
|
try {
|
||||||
body.loading = true
|
body.loading = true
|
||||||
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
||||||
|
const { token, customer } = verified
|
||||||
session.login(token, customer.id, customer.mobile)
|
session.login(token, customer.id, customer.mobile)
|
||||||
// D21 — ask the server which kind of principal this token is, so the
|
// D21 — ask the server which kind of principal this token is, so the
|
||||||
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
||||||
@@ -171,6 +172,10 @@ const sendToken = async (otpCode: string) => {
|
|||||||
} catch {
|
} catch {
|
||||||
session.setSalesExpert(false)
|
session.setSalesExpert(false)
|
||||||
}
|
}
|
||||||
|
if (verified.iamBootstrapUrl) {
|
||||||
|
window.location.assign(verified.iamBootstrapUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
||||||
await router.push(redirect ?? { name: 'home' })
|
await router.push(redirect ?? { name: 'home' })
|
||||||
toast.success('با موفقیت وارد شدید')
|
toast.success('با موفقیت وارد شدید')
|
||||||
|
|||||||
+32
-4
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| موضوع | تصمیم |
|
| موضوع | تصمیم |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| رابط کاربری | **هیچ تغییری نمیکند**؛ نه دکمه SSO، نه ریدایرکت، نه صفحه جدید |
|
| رابط کاربری | فرم ورود همان است. با IAM روشن، یک پرش بیصدای `prompt=none` و صفحه `/auth/iam/callback` اضافه شده تا ورود مشترک با همکار کار کند |
|
||||||
| جای اتصال | فقط همان یک نقطهای که رمز بررسی میشد (`AuthService.login`) |
|
| جای اتصال | فقط همان یک نقطهای که رمز بررسی میشد (`AuthService.login`) |
|
||||||
| پیشفرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز |
|
| پیشفرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز |
|
||||||
| در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمینها قطع نمیشود |
|
| در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمینها قطع نمیشود |
|
||||||
@@ -86,8 +86,9 @@ curl -X POST https://auth.example.com/api/v1/admin/clients \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
> `redirect_uris` در این حالت استفاده نمیشود (جریان مرورگری نداریم) ولی
|
> `redirect_uris` آدرس callback فرانت است و باید دقیقاً با صفحهای که مرورگر
|
||||||
> IAM آن را اجباری میداند؛ یک آدرس معتبر بگذارید.
|
> باز میکند یکی باشد. برای اپ کاربر و پنل ادمین، و برای دولوپ و پروداکشن،
|
||||||
|
> هر کدام یک خط جدا. جزئیات در بخش «ورود مشترک با همکار».
|
||||||
|
|
||||||
### گام ۲ — تنظیم `.env`
|
### گام ۲ — تنظیم `.env`
|
||||||
|
|
||||||
@@ -192,7 +193,34 @@ POST /api/iam/backchannel-logout ← این آدرس را در IAM بهعن
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ۶. تست
|
## ۶. ورود مشترک با همکار
|
||||||
|
|
||||||
|
کوکی نشست روی خود IAM است (`iam.montest.ir`) و بین دامنهها مشترک نمیشود.
|
||||||
|
ورود در یک سامانه فقط وقتی سامانهٔ دیگر را هم باز میکند که:
|
||||||
|
|
||||||
|
1. ورود محلی بلیت مرورگر گرفته باشد و فرانت به `iamBootstrapUrl` رفته باشد.
|
||||||
|
2. سامانهٔ دوم بدون توکن محلی، `prompt=none` بزند و callback **همان صفحه** باشد.
|
||||||
|
3. همان شماره موبایل در سامانهٔ دوم از قبل حساب داشته باشد. IAM حساب gold-bank
|
||||||
|
یا همکار را نمیسازد.
|
||||||
|
|
||||||
|
کلاینت gold-bank جدا از `hamkar_server` است (`goldbank_api`). آدرسها عیناً،
|
||||||
|
هر کدام یک خط در فرم IAM و با کاما در env:
|
||||||
|
|
||||||
|
| نقش | callback (آدرسهای بازگشت) | بعد از خروج |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| اپ کاربر | `IAM_REDIRECT_URI` و `IAM_REDIRECT_URIS` | `IAM_POST_LOGOUT_REDIRECT_URI` و `IAM_ALLOWED_RETURN_URLS` |
|
||||||
|
| پنل ادمین | `IAM_ADMIN_REDIRECT_URI` و `IAM_ADMIN_REDIRECT_URIS` | `IAM_ADMIN_POST_LOGOUT_REDIRECT_URI` و `IAM_ADMIN_ALLOWED_RETURN_URLS` |
|
||||||
|
|
||||||
|
فرانت آدرس خودش را میفرستد (`origin` + `/auth/iam/callback`؛ پنل شعبه پیشوند
|
||||||
|
`/branch` دارد). اگر آن رشته در فهرست نباشد، بررسی بیصدا انجام نمیشود و
|
||||||
|
صفحهٔ لاگین محلی میماند. خروج هم اگر مقصدش ثبت نشده باشد به IAM نمیرود.
|
||||||
|
|
||||||
|
وقتی `IAM_ENABLED` خاموش است، `sso/start` مقدار `enabled: false` میدهد و
|
||||||
|
`sso/logout-url` مقدار `logout_url: null`. هیچ پیام خطایی برای خروج لازم نیست.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. تست
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/api
|
cd apps/api
|
||||||
|
|||||||
Reference in New Issue
Block a user