SSENginx #9
93
SSO-Frontend-Microsoft.md
Normal file
93
SSO-Frontend-Microsoft.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Frontend Guide - Microsoft SSO (Entra ID)
|
||||
|
||||
## 1) Muc tieu
|
||||
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
|
||||
|
||||
## 2) Redirect URI da dang ky
|
||||
Redirect URI cho Microsoft app:
|
||||
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
|
||||
Luu y:
|
||||
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
|
||||
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
|
||||
|
||||
## 3) Endpoint frontend can goi
|
||||
Backend route SSO Microsoft (legacy route):
|
||||
|
||||
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Route alias cung ho tro:
|
||||
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/sso/exchange
|
||||
|
||||
## 4) Login flow cho frontend
|
||||
1. User bam nut "Login with Microsoft".
|
||||
2. Frontend redirect browser den:
|
||||
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
3. Backend redirect sang Microsoft login page.
|
||||
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
|
||||
?code={ONE_TIME_CODE}
|
||||
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
|
||||
7. Frontend luu token va thong tin user, dieu huong vao app.
|
||||
|
||||
## 5) API exchange chi tiet
|
||||
Method: POST
|
||||
Path: /api/auth/sso/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
Request body:
|
||||
{
|
||||
"code": "<one-time-code>"
|
||||
}
|
||||
|
||||
Success response (HTTP 200):
|
||||
{
|
||||
"token": "<jwt>",
|
||||
"name": "Nguyen Van A",
|
||||
"username": "user@hust.edu.vn",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
|
||||
## 6) Error handling goi y cho frontend
|
||||
- 400 BadRequest: code thieu hoac token khong hop le.
|
||||
- 401 Unauthorized: code het han / da dung / khong hop le.
|
||||
- 404 NotFound: user khong ton tai sau khi exchange.
|
||||
|
||||
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
|
||||
Neu exchange that bai, can yeu cau user login lai.
|
||||
|
||||
## 7) Mau frontend pseudo-code
|
||||
const returnUrl = window.location.origin + "/sso/complete";
|
||||
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
|
||||
// tai /sso/complete
|
||||
const code = new URLSearchParams(window.location.search).get("code");
|
||||
if (code) {
|
||||
const res = await fetch("/api/auth/sso/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// hien thi thong bao loi va cho user thu lai
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// save data.token, data.username, data.role, data.access
|
||||
}
|
||||
}
|
||||
|
||||
## 8) Checklist truoc khi UAT
|
||||
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
|
||||
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
|
||||
- Frontend parse duoc query code
|
||||
- Frontend goi exchange ngay sau khi nhan code
|
||||
- Frontend xu ly day du HTTP 400/401/404
|
||||
|
|
@ -109,7 +109,7 @@ server {
|
|||
location /api/ {
|
||||
proxy_pass http://$backend_server;
|
||||
|
||||
client_max_body_size 200M;
|
||||
client_max_body_size 900M;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { LoginResquest } from "@/types/auth";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -46,12 +46,22 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "google");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "azuread");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
|
@ -133,6 +143,15 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</svg>
|
||||
Đăng nhập với Google
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
|
||||
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
|
||||
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
|
||||
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
|||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
// Match server allowed extensions
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
||||
const isFileValid = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeOAuthCode } from "@/services/auth.service";
|
||||
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
|
|
@ -18,18 +18,21 @@ export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
|||
function OAuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = search.code;
|
||||
const provider = (search.provider || "").toLowerCase();
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumedCodes.has(code)) {
|
||||
const exchangeId = `${provider || "auto"}:${code}`;
|
||||
|
||||
if (consumedCodes.has(exchangeId)) {
|
||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -38,10 +41,10 @@ function OAuthCallbackPage() {
|
|||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(code);
|
||||
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeOAuthCode(code);
|
||||
inFlightExchanges.set(code, exchangePromise);
|
||||
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
|
|
@ -53,7 +56,7 @@ function OAuthCallbackPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(code);
|
||||
consumedCodes.add(exchangeId);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
|
|
@ -74,14 +77,14 @@ function OAuthCallbackPage() {
|
|||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
consumedCodes.add(code);
|
||||
consumedCodes.add(exchangeId);
|
||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(code);
|
||||
inFlightExchanges.delete(exchangeId);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
|
|
@ -90,7 +93,7 @@ function OAuthCallbackPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.redirect]);
|
||||
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,25 @@ function RouteComponent() {
|
|||
roomName as string,
|
||||
);
|
||||
|
||||
const sortedFolderStatusList = useMemo(() => {
|
||||
return [...(folderStatusList ?? [])].sort((a, b) => {
|
||||
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
|
||||
if (aRoom || bRoom) {
|
||||
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [folderStatusList]);
|
||||
|
||||
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -80,7 +99,7 @@ function RouteComponent() {
|
|||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: folderStatusList ?? [],
|
||||
data: sortedFolderStatusList,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
|
@ -88,7 +107,7 @@ function RouteComponent() {
|
|||
return (
|
||||
<FolderStatusTemplate
|
||||
roomName={roomName as string}
|
||||
data={folderStatusList}
|
||||
data={sortedFolderStatusList}
|
||||
isLoading={isLoading}
|
||||
onBack={() =>
|
||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
|||
return buildOAuthLoginUrl("google", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Microsoft SSO login URL (legacy endpoint)
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time OAuth code for login payload
|
||||
* @param code - one-time code
|
||||
|
|
@ -75,6 +85,27 @@ export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
|||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time code by provider without breaking existing flows.
|
||||
* - azuread/microsoft: force legacy SSO exchange endpoint
|
||||
* - default: use OAuth exchange flow
|
||||
*/
|
||||
export async function exchangeCodeByProvider(
|
||||
code: string,
|
||||
provider?: string
|
||||
): Promise<LoginResponse> {
|
||||
const providerKey = (provider || "").toLowerCase();
|
||||
if (providerKey === "microsoft" || providerKey === "azuread") {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng xuất
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export type UserProfile = {
|
|||
name: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
accessRooms: number[];
|
||||
accessRooms: string[];
|
||||
createdAt?: string | null;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user