add oauth login
This commit is contained in:
parent
c9960aea75
commit
53c27c4efc
343
SSO-OAuth-OIDC.md
Normal file
343
SSO-OAuth-OIDC.md
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
# Google OAuth (OIDC) + AzureAD Shared SSO Service
|
||||||
|
|
||||||
|
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
|
||||||
|
|
||||||
|
Pham vi:
|
||||||
|
- Giu nguyen login username/password cu.
|
||||||
|
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
|
||||||
|
- Van tao/cap nhat user noi bo trong DB.
|
||||||
|
- User moi vao role Pending (khong co permission), doi admin cap quyen.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 1) Trang thai implementation hien tai
|
||||||
|
|
||||||
|
Da hoan tat trong code:
|
||||||
|
- Shared service theo provider key: SsoService.
|
||||||
|
- Controller da provider: OAuthController.
|
||||||
|
- Controller AzureAD cu (SsoController) da chay tren shared service.
|
||||||
|
- Config da provider qua OAuthProviders trong appsettings.
|
||||||
|
|
||||||
|
File chinh:
|
||||||
|
- TTMT.CompManageWeb/Program.cs
|
||||||
|
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
|
||||||
|
- TTMT.CompManageWeb/Services/SsoSerivce.cs
|
||||||
|
- TTMT.CompManageWeb/Controllers/OAuthController.cs
|
||||||
|
- TTMT.CompManageWeb/Controllers/SsoController.cs
|
||||||
|
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
|
||||||
|
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
|
||||||
|
- TTMT.CompManageWeb/appsettings.json
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 2) Kien truc luong dang nhap
|
||||||
|
|
||||||
|
### 2.1 Luong OAuth Google
|
||||||
|
|
||||||
|
1. FE redirect user den:
|
||||||
|
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
|
||||||
|
2. Backend build authorize URL va redirect qua Google.
|
||||||
|
3. Google callback ve backend:
|
||||||
|
- GET /api/auth/oauth/google/callback?code=...&state=...
|
||||||
|
4. Backend:
|
||||||
|
- Exchange code -> token endpoint.
|
||||||
|
- Validate id_token.
|
||||||
|
- Lay email/name.
|
||||||
|
- Kiem tra domain.
|
||||||
|
- Upsert user noi bo.
|
||||||
|
- Issue JWT noi bo.
|
||||||
|
- Tao one-time code (2 phut).
|
||||||
|
- Redirect ve returnUrl?code=<one-time-code>.
|
||||||
|
5. FE goi API exchange:
|
||||||
|
- POST /api/auth/oauth/exchange
|
||||||
|
- Body: { "code": "..." }
|
||||||
|
6. Backend consume one-time code va tra payload login (token + role + permission).
|
||||||
|
|
||||||
|
### 2.2 Luong AzureAD cu
|
||||||
|
|
||||||
|
Van giu endpoint cu:
|
||||||
|
- GET /api/auth/sso/login
|
||||||
|
- GET /api/auth/sso/callback
|
||||||
|
- POST /api/auth/sso/exchange
|
||||||
|
|
||||||
|
Nhung ben trong da dung chung service theo provider azuread.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 3) Cau hinh bat buoc
|
||||||
|
|
||||||
|
### 3.1 appsettings.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OAuthProviders": {
|
||||||
|
"DefaultProvider": "azuread",
|
||||||
|
"Providers": {
|
||||||
|
"google": {
|
||||||
|
"Authority": "https://accounts.google.com",
|
||||||
|
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
"TokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"CallbackPath": "/api/auth/oauth/google/callback",
|
||||||
|
"AllowedDomain": "",
|
||||||
|
"PendingRoleName": "Pending",
|
||||||
|
"Scopes": "openid profile email",
|
||||||
|
"Issuer": "https://accounts.google.com",
|
||||||
|
"EmailClaim": "email",
|
||||||
|
"NameClaim": "name",
|
||||||
|
"HostedDomainClaimName": "hd",
|
||||||
|
"EnforceHostedDomainClaim": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Giai thich nhanh:
|
||||||
|
- AllowedDomain:
|
||||||
|
- Rong: cho phep moi domain.
|
||||||
|
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
|
||||||
|
- EnforceHostedDomainClaim=true:
|
||||||
|
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
|
||||||
|
- Neu token khong co hd, he thong fallback check theo email domain.
|
||||||
|
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
|
||||||
|
|
||||||
|
### 3.2 Google Cloud Console
|
||||||
|
|
||||||
|
1. Tao OAuth client (Web application).
|
||||||
|
2. Authorized redirect URIs:
|
||||||
|
- https://<your-domain>/api/auth/oauth/google/callback
|
||||||
|
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
|
||||||
|
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 4) Dang ky DI va options
|
||||||
|
|
||||||
|
Trong Program.cs can co:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.Configure<AzureAdOptions>(
|
||||||
|
builder.Configuration.GetSection(AzureAdOptions.SectionName));
|
||||||
|
|
||||||
|
builder.Services.Configure<OAuthProvidersOptions>(
|
||||||
|
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ISsoService, SsoService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 5) Thiet ke interface va service chung
|
||||||
|
|
||||||
|
### 5.1 Interface ISsoService
|
||||||
|
|
||||||
|
Interface da duoc mo rong thanh provider-aware:
|
||||||
|
- BuildAuthorizeUrl(provider, redirectUri, state)
|
||||||
|
- ExchangeCodeAsync(provider, code, redirectUri, ct)
|
||||||
|
- ValidateIdTokenAsync(provider, idToken, ct)
|
||||||
|
- IsAllowedDomain(provider, email, principal)
|
||||||
|
- UpsertUserAsync(provider, email, name, ct)
|
||||||
|
|
||||||
|
Va van giu overload cu de backward compatibility.
|
||||||
|
|
||||||
|
### 5.2 SsoService - logic tong
|
||||||
|
|
||||||
|
Core y tuong:
|
||||||
|
- Resolve config theo provider key.
|
||||||
|
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
|
||||||
|
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
|
||||||
|
|
||||||
|
Phan quan trong:
|
||||||
|
|
||||||
|
1) ResolveProvider(...)
|
||||||
|
- Doc provider trong OAuthProviders.
|
||||||
|
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
|
||||||
|
|
||||||
|
2) BuildAuthorizeUrl(...)
|
||||||
|
- Build URL authorize theo endpoint cua provider.
|
||||||
|
- Them scope, state, redirect_uri.
|
||||||
|
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
|
||||||
|
|
||||||
|
3) ExchangeCodeAsync(...)
|
||||||
|
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
|
||||||
|
- Parse OidcTokenResponse.
|
||||||
|
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
|
||||||
|
|
||||||
|
4) ValidateIdTokenAsync(...)
|
||||||
|
- Lay metadata OIDC (.well-known/openid-configuration).
|
||||||
|
- Validate issuer, audience, signing key, lifetime.
|
||||||
|
|
||||||
|
5) IsAllowedDomain(...)
|
||||||
|
- Neu AllowedDomain rong -> cho phep.
|
||||||
|
- Neu khong rong -> check duoi email.
|
||||||
|
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
|
||||||
|
|
||||||
|
6) UpsertUserAsync(...)
|
||||||
|
- Tim user theo email (UserName).
|
||||||
|
- Neu ton tai: cap nhat ten + metadata update.
|
||||||
|
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
|
||||||
|
|
||||||
|
7) One-time code
|
||||||
|
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
|
||||||
|
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 6) Controller va contract API
|
||||||
|
|
||||||
|
### 6.1 OAuthController (da provider)
|
||||||
|
|
||||||
|
Route base: api/auth/oauth
|
||||||
|
|
||||||
|
1) Login
|
||||||
|
- GET /api/auth/oauth/{provider}/login?returnUrl=...
|
||||||
|
- Redirect sang provider authorize URL.
|
||||||
|
|
||||||
|
2) Callback
|
||||||
|
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
|
||||||
|
- Xu ly token + domain + upsert + issue token + one-time code.
|
||||||
|
- Redirect ve FE voi query code.
|
||||||
|
|
||||||
|
3) Exchange
|
||||||
|
- POST /api/auth/oauth/exchange
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "one-time-code"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response thanh cong:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "<jwt-noi-bo>",
|
||||||
|
"name": "...",
|
||||||
|
"username": "email@domain",
|
||||||
|
"access": [1, 2, 3],
|
||||||
|
"role": {
|
||||||
|
"roleName": "Pending",
|
||||||
|
"priority": 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 SsoController (AzureAD legacy)
|
||||||
|
|
||||||
|
Van giu route cu, nhung da goi service chung voi provider azuread.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 7) Rule user va role Pending
|
||||||
|
|
||||||
|
Rule bat buoc:
|
||||||
|
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
|
||||||
|
- User moi phai vao role Pending.
|
||||||
|
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
|
||||||
|
- Admin se cap role/permission sau.
|
||||||
|
|
||||||
|
Model lien quan:
|
||||||
|
- UserAccounts
|
||||||
|
- Roles
|
||||||
|
- PermissionRoles
|
||||||
|
- SsoOneTimeCodes
|
||||||
|
|
||||||
|
Kiem tra nhanh trong DB:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) Kiem tra role Pending co ton tai
|
||||||
|
SELECT Id, RoleName, Priority
|
||||||
|
FROM "Roles"
|
||||||
|
WHERE "RoleName" = 'Pending';
|
||||||
|
|
||||||
|
-- 2) Kiem tra Pending role khong co permission active
|
||||||
|
SELECT pr.*
|
||||||
|
FROM "PermissionRoles" pr
|
||||||
|
JOIN "Roles" r ON r."Id" = pr."RoleId"
|
||||||
|
WHERE r."RoleName" = 'Pending'
|
||||||
|
AND pr."IsChecked" = 1;
|
||||||
|
|
||||||
|
-- 3) Kiem tra user tao boi OAuth
|
||||||
|
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
|
||||||
|
FROM "UserAccounts"
|
||||||
|
WHERE "UserName" = '<email-user>';
|
||||||
|
```
|
||||||
|
|
||||||
|
Ky vong:
|
||||||
|
- Query #2 tra ve 0 rows.
|
||||||
|
- User moi co Password = null, CreatedBy = 'SSO'.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 8) Frontend integration (chi tiet)
|
||||||
|
|
||||||
|
1. Nguoi dung bam nut Google Login:
|
||||||
|
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
|
||||||
|
2. Sau callback backend, FE nhan code tu query string.
|
||||||
|
3. FE goi:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/oauth/exchange
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "code": "<code-tu-query>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
4. FE luu token va xu ly permission/role giong login cu.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 9) Luu y bao mat va hardening
|
||||||
|
|
||||||
|
Nen lam tiep:
|
||||||
|
- Validate returnUrl theo allowlist de tranh open redirect.
|
||||||
|
- Luu va verify state/nonce server-side (cache/redis).
|
||||||
|
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
|
||||||
|
- Dua ClientSecret sang secret manager/env var.
|
||||||
|
- Bat HTTPS va secure cookie policy day du.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 10) Troubleshooting
|
||||||
|
|
||||||
|
1) Loi IdToken not found in token response
|
||||||
|
- Provider dang khong tra OIDC token.
|
||||||
|
- Kiem tra scope co openid chua.
|
||||||
|
|
||||||
|
2) Loi Email not found in token
|
||||||
|
- Kiem tra claim trong Google token (email, preferred_username).
|
||||||
|
|
||||||
|
3) Loi Email domain is not allowed
|
||||||
|
- Check AllowedDomain va claim hd.
|
||||||
|
|
||||||
|
4) Loi Role 'Pending' not found
|
||||||
|
- Tao role Pending trong bang Roles.
|
||||||
|
|
||||||
|
5) Loi exchange code het han
|
||||||
|
- One-time code chi song 2 phut va chi dung 1 lan.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 11) Checklist test E2E truoc khi merge
|
||||||
|
|
||||||
|
- Login Google thanh cong voi account hop le.
|
||||||
|
- User moi duoc tao trong DB va role = Pending.
|
||||||
|
- Pending role khong co permission active.
|
||||||
|
- User cu login lai thi khong tao duplicate user.
|
||||||
|
- One-time code chi dung 1 lan.
|
||||||
|
- AllowedDomain rong cho phep tat ca domain.
|
||||||
|
- AllowedDomain co gia tri thi chan dung domain.
|
||||||
|
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
## 12) Ghi chu pham vi hien tai
|
||||||
|
|
||||||
|
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
|
||||||
|
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
|
||||||
|
- UserInfo endpoint call.
|
||||||
|
- Mapping email/name tu userinfo response.
|
||||||
|
- Branch logic trong callback de fallback userinfo.
|
||||||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { LoginResquest } from "@/types/auth";
|
import type { LoginResquest } from "@/types/auth";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { buildSsoLoginUrl, login } from "@/services/auth.service";
|
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
import { Route } from "@/routes/(auth)/login";
|
import { Route } from "@/routes/(auth)/login";
|
||||||
|
|
@ -44,12 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSsoLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
const returnUrl = new URL("/sso/callback", window.location.origin);
|
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||||
if (search.redirect) {
|
if (search.redirect) {
|
||||||
returnUrl.searchParams.set("redirect", search.redirect);
|
returnUrl.searchParams.set("redirect", search.redirect);
|
||||||
}
|
}
|
||||||
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
|
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
|
@ -112,14 +112,26 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
||||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
|
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||||
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
|
<path
|
||||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
|
fill="#4285F4"
|
||||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
|
||||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Đăng nhập với Microsoft
|
Đăng nhập với Google
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export const BASE_MESH_URL = isDev
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: `${BASE_URL}/login`,
|
LOGIN: `${BASE_URL}/login`,
|
||||||
|
OAUTH_LOGIN: (provider: string) =>
|
||||||
|
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
|
||||||
|
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
|
||||||
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
||||||
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
||||||
LOGOUT: `${BASE_URL}/logout`,
|
LOGOUT: `${BASE_URL}/logout`,
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,17 @@ export function useCreateAccount() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook để đổi one-time code SSO lấy payload đăng nhập
|
* Hook để đổi one-time OAuth code lấy payload đăng nhập
|
||||||
*/
|
*/
|
||||||
export function useExchangeSsoCode() {
|
export function useExchangeOAuthCode() {
|
||||||
return useMutation<LoginResponse, any, string>({
|
return useMutation<LoginResponse, any, string>({
|
||||||
mutationFn: (code) => authService.exchangeSsoCode(code),
|
mutationFn: (code) => authService.exchangeOAuthCode(code),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy alias for backward compatibility.
|
||||||
|
*/
|
||||||
|
export function useExchangeSsoCode() {
|
||||||
|
return useExchangeOAuthCode();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
|
||||||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
||||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||||
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
|
import { Route as authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
|
||||||
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||||
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
|
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
|
||||||
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||||
|
|
@ -132,9 +132,9 @@ const AuthProfileUserNameIndexRoute =
|
||||||
path: '/profile/$userName/',
|
path: '/profile/$userName/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
|
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
|
||||||
id: '/(auth)/sso/callback/',
|
id: '/(auth)/oauth/callback/',
|
||||||
path: '/sso/callback/',
|
path: '/oauth/callback/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||||
|
|
@ -186,7 +186,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
'/user': typeof AuthUserIndexRoute
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -213,7 +213,7 @@ export interface FileRoutesByTo {
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
'/user': typeof AuthUserIndexRoute
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -242,7 +242,7 @@ export interface FileRoutesById {
|
||||||
'/_auth/role/': typeof AuthRoleIndexRoute
|
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||||
'/_auth/user/': typeof AuthUserIndexRoute
|
'/_auth/user/': typeof AuthUserIndexRoute
|
||||||
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
|
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
|
||||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -271,7 +271,7 @@ export interface FileRouteTypes {
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
| '/user'
|
| '/user'
|
||||||
| '/sso/callback'
|
| '/oauth/callback'
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
|
|
@ -298,7 +298,7 @@ export interface FileRouteTypes {
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
| '/user'
|
| '/user'
|
||||||
| '/sso/callback'
|
| '/oauth/callback'
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
|
|
@ -326,7 +326,7 @@ export interface FileRouteTypes {
|
||||||
| '/_auth/role/'
|
| '/_auth/role/'
|
||||||
| '/_auth/rooms/'
|
| '/_auth/rooms/'
|
||||||
| '/_auth/user/'
|
| '/_auth/user/'
|
||||||
| '/(auth)/sso/callback/'
|
| '/(auth)/oauth/callback/'
|
||||||
| '/_auth/profile/$userName/'
|
| '/_auth/profile/$userName/'
|
||||||
| '/_auth/profile/change-password/'
|
| '/_auth/profile/change-password/'
|
||||||
| '/_auth/role/create/'
|
| '/_auth/role/create/'
|
||||||
|
|
@ -344,7 +344,7 @@ export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
authLoginIndexRoute: typeof authLoginIndexRoute
|
||||||
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
|
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|
@ -482,11 +482,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/(auth)/sso/callback/': {
|
'/(auth)/oauth/callback/': {
|
||||||
id: '/(auth)/sso/callback/'
|
id: '/(auth)/oauth/callback/'
|
||||||
path: '/sso/callback'
|
path: '/oauth/callback'
|
||||||
fullPath: '/sso/callback'
|
fullPath: '/oauth/callback'
|
||||||
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
|
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_auth/user/role/$roleId/': {
|
'/_auth/user/role/$roleId/': {
|
||||||
|
|
@ -592,7 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
authLoginIndexRoute: authLoginIndexRoute,
|
authLoginIndexRoute: authLoginIndexRoute,
|
||||||
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
|
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,60 @@
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useExchangeSsoCode } from "@/hooks/queries";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { exchangeOAuthCode } from "@/services/auth.service";
|
||||||
|
import type { LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/sso/callback/")({
|
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||||
component: SsoCallbackPage,
|
const consumedCodes = new Set<string>();
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||||
|
component: OAuthCallbackPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function SsoCallbackPage() {
|
function OAuthCallbackPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const exchangeMutation = useExchangeSsoCode();
|
|
||||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isExchanging, setIsExchanging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!search.code) {
|
const code = search.code;
|
||||||
setErrorMessage("SSO code is missing.");
|
if (!code) {
|
||||||
|
setErrorMessage("OAuth code is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumedCodes.has(code)) {
|
||||||
|
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
exchangeMutation.mutate(search.code, {
|
setIsExchanging(true);
|
||||||
onSuccess: async (data) => {
|
|
||||||
|
let cancelled = false;
|
||||||
|
let exchangePromise = inFlightExchanges.get(code);
|
||||||
|
if (!exchangePromise) {
|
||||||
|
exchangePromise = exchangeOAuthCode(code);
|
||||||
|
inFlightExchanges.set(code, exchangePromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
exchangePromise
|
||||||
|
.then(async (data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
if (!data.token) {
|
if (!data.token) {
|
||||||
setErrorMessage("SSO response missing token.");
|
setErrorMessage("OAuth response missing token.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
consumedCodes.add(code);
|
||||||
|
|
||||||
localStorage.setItem("token", data.token);
|
localStorage.setItem("token", data.token);
|
||||||
localStorage.setItem("username", data.username || "");
|
localStorage.setItem("username", data.username || "");
|
||||||
localStorage.setItem("name", data.name || "");
|
localStorage.setItem("name", data.name || "");
|
||||||
|
|
@ -45,22 +69,38 @@ function SsoCallbackPage() {
|
||||||
auth.login(data.username || "");
|
auth.login(data.username || "");
|
||||||
|
|
||||||
await navigate({ to: search.redirect || "/dashboard" });
|
await navigate({ to: search.redirect || "/dashboard" });
|
||||||
},
|
})
|
||||||
onError: () => {
|
.catch((error) => {
|
||||||
setErrorMessage("SSO exchange failed.");
|
if (cancelled) return;
|
||||||
},
|
|
||||||
});
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
|
consumedCodes.add(code);
|
||||||
|
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);
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsExchanging(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [auth, navigate, search.code, search.redirect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
|
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
|
||||||
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col items-center gap-4">
|
<CardContent className="flex flex-col items-center gap-4">
|
||||||
{exchangeMutation.isPending && (
|
{isExchanging && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||||
Đang trao đổi mã đăng nhập
|
Đang trao đổi mã đăng nhập
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
|
import rawAxios from "axios";
|
||||||
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,25 +17,62 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build SSO login URL
|
* Build OAuth login URL by provider
|
||||||
|
* @param provider - OAuth provider key (e.g. google, azuread)
|
||||||
* @param returnUrl - FE callback url
|
* @param returnUrl - FE callback url
|
||||||
*/
|
*/
|
||||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
|
||||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
|
||||||
const encoded = encodeURIComponent(returnUrl);
|
const encoded = encodeURIComponent(returnUrl);
|
||||||
return `${base}?returnUrl=${encoded}`;
|
return `${base}?returnUrl=${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange one-time code for login payload
|
* Build Google OAuth login URL
|
||||||
|
* @param returnUrl - FE callback url
|
||||||
|
*/
|
||||||
|
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
||||||
|
return buildOAuthLoginUrl("google", returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange one-time OAuth code for login payload
|
||||||
* @param code - one-time code
|
* @param code - one-time code
|
||||||
*/
|
*/
|
||||||
|
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
|
||||||
|
try {
|
||||||
|
const response = await rawAxios.post<LoginResponse>(
|
||||||
|
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
|
||||||
|
{ code }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (rawAxios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status === 401 || status === 404 || status === 405) {
|
||||||
|
const fallbackResponse = await rawAxios.post<LoginResponse>(
|
||||||
|
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||||
|
{ code }
|
||||||
|
);
|
||||||
|
return fallbackResponse.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy AzureAD SSO URL builder kept for backward compatibility.
|
||||||
|
*/
|
||||||
|
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||||
|
return buildOAuthLoginUrl("azuread", returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy SSO exchange alias kept for backward compatibility.
|
||||||
|
*/
|
||||||
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
||||||
const response = await axios.post<LoginResponse>(
|
return exchangeOAuthCode(code);
|
||||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
|
||||||
{ code }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user