Skip to content

11.13 - Ví dụ thực tế nhanh ​

Tóm tắt: Một hạn chế kỹ thuật dẫn tới một lỗ hổng bảo mật: WebSocket API của trình duyệt không cho gửi header tuỳ chỉnh, nên không đính được Authorization: Bearer ... như mọi lời gọi HTTP khác. Cách giải quyết chuẩn của SignalR là đưa token vào query string — và nó đúng, nhưng kéo theo một hậu quả ít ai nghĩ tới: query string bị ghi vào access log của reverse proxy, của CDN, và của mọi tầng hạ tầng ở giữa. Một file log bị chia sẻ để gỡ lỗi có thể chứa hàng nghìn token còn hạn. Bài này trình bày cách xác thực hub đúng, cách che token trong log, và cách phân quyền ở cấp phương thức hub.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Xác thực kết nối SignalR đúng cách.
  • Che token khỏi access log.
  • Phân quyền ở cấp phương thức hub.
  • Xử lý token hết hạn trong kết nối dài.

Nội dung bài học ​

11.13.1 — Vấn đề ​

javascript
// HTTP thông thường — gửi header được
fetch('/api/leads', { headers: { Authorization: `Bearer ${token}` } });

// WebSocket — API trình duyệt KHÔNG cho gửi header tuỳ chỉnh
new WebSocket('wss://api.company.com/hubs/notifications');
//            ^^ không có chỗ để đặt header

Đây là hạn chế của chuẩn WebSocket trong trình duyệt, không phải của SignalR. Cách giải quyết chuẩn:

javascript
const connection = new signalR.HubConnectionBuilder()
    .withUrl('/hubs/notifications', {
        accessTokenFactory: () => getAccessToken()     // SignalR dua vao query string
    })
    .withAutomaticReconnect()
    .build();
Request thực tế:
GET /hubs/notifications?access_token=eyJhbGciOiJIUzI1NiIs...
csharp
// Phia server — doc token tu query string
builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                // CHỈ cho endpoint hub — không áp dụng cho API thường
                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }

                return Task.CompletedTask;
            }
        };
    });

Điều kiện path.StartsWithSegments("/hubs") quan trọng: không có nó, mọi API đều chấp nhận token qua query string — và khi đó mọi lời gọi API cũng lộ token vào log.

11.13.2 — Hậu quả: token trong log ​

# Access log cua Nginx
10.0.1.5 - - [24/Sep/2026:10:15:32] "GET /hubs/notifications?access_token=eyJhbGci... HTTP/1.1" 101

Query string bị ghi vào mọi tầng:

NơiRủi ro
Access log của reverse proxyFile log chia sẻ khi gỡ lỗi
Log của CDNThường xuất ra hệ thống phân tích của bên thứ ba
Log của load balancerLưu trữ dài hạn theo chính sách hạ tầng
Lịch sử trình duyệtNếu URL bị mở trực tiếp

Token còn hạn trong log nghĩa là ai đọc được log thì đăng nhập được với tư cách người dùng đó.

Sửa — che trong cấu hình proxy:

nginx
# Nginx — định nghĩa định dạng log có che access_token
map $request_uri $loggable_uri {
    ~^(?<prefix>[^?]*)\?.*access_token=[^&]*(?<suffix>.*)$  "$prefix?access_token=***$suffix";
    default $request_uri;
}

log_format masked '$remote_addr - [$time_local] "$request_method $loggable_uri" $status';
access_log /var/log/nginx/access.log masked;

Và ở phía ứng dụng:

csharp
builder.Services.AddHttpLogging(o =>
{
    o.LoggingFields = HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode;
    // KHÔNG bao gồm RequestQuery
});

Giảm nhẹ quan trọng nhất: giữ access token ngắn hạn (5–15 phút). Token trong log cũ hết hạn nhanh, nên cửa sổ rủi ro nhỏ (bài 10.12).

11.13.3 — Phân quyền trong hub ​

csharp
[Authorize]
public sealed class NotificationHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
        if (tenantId is null)
        {
            Context.Abort();          // tu choi ket noi
            return;
        }

        // Nhóm theo tenant — để gửi thông báo đúng phạm vi
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}");
        await base.OnConnectedAsync();
    }

    [Authorize(Roles = "SalesManager")]
    public async Task BroadcastToTeam(string message)
    {
        var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
        await Clients.Group($"tenant-{tenantId}").SendAsync("TeamMessage", message);
    }
}

Ba điểm quan trọng:

  • [Authorize] trên lớp áp dụng cho mọi phương thức, kể cả OnConnectedAsync.
  • [Authorize(Roles = ...)] trên phương thức cho phân quyền chi tiết hơn — client gọi phương thức không có quyền sẽ nhận lỗi.
  • Nhóm theo tenant là cách gửi đúng phạm vi. Clients.All trong hệ multi-tenant gần như luôn sai — nó gửi cho mọi công ty.

Điểm cuối đáng nhấn mạnh: đây là lỗi rò rỉ dữ liệu giữa tenant, cùng lớp với bài 7.11 nhưng qua một con đường khác.

11.13.4 — Gửi cho đúng người dùng ​

csharp
// Gửi theo user id — SignalR tự tìm MỌI kết nối của người đó
await _hubContext.Clients.User(userId.ToString()).SendAsync("NewLead", lead, ct);

Clients.User gửi tới mọi thiết bị của người dùng đó — điện thoại, máy tính, nhiều tab. Đó thường là hành vi bạn muốn.

Nó dựa vào IUserIdProvider để biết claim nào là id người dùng:

csharp
public sealed class CustomUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection)
        => connection.User?.FindFirst("sub")?.Value;      // hoac claim ban dung
}

builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();

Mặc định SignalR dùng ClaimTypes.NameIdentifier. Nếu token của bạn dùng claim khác — sub theo chuẩn JWT chẳng hạn — Clients.User sẽ không tìm thấy ai và thông báo biến mất im lặng. Đây là lỗi hay gặp và khó chẩn đoán vì không có thông báo lỗi nào.

11.13.5 — Token hết hạn trong kết nối dài ​

Access token: 15 phut
Ket noi WebSocket: co the mo hang gio

-> Sau 15 phut, token trong ket noi da het han

SignalR không tự kiểm tra lại token trên kết nối đang mở, nên kết nối vẫn hoạt động. Điều đó có thể chấp nhận được hoặc không, tuỳ nghiệp vụ.

Nếu cần đảm bảo, buộc kết nối lại định kỳ:

javascript
const connection = new signalR.HubConnectionBuilder()
    .withUrl('/hubs/notifications', {
        accessTokenFactory: async () => {
            // Tự động làm mới token nếu sắp hết hạn
            if (isTokenExpiringSoon()) await refreshToken();
            return getAccessToken();
        }
    })
    .withAutomaticReconnect([0, 2000, 10000, 30000])
    .build();

accessTokenFactory được gọi lại mỗi lần kết nối lại, nên token mới được dùng tự động.

Mảng truyền vào withAutomaticReconnect là các khoảng chờ trước mỗi lần thử: ngay lập tức, 2 giây, 10 giây, 30 giây. Mặc định chỉ thử 4 lần rồi bỏ cuộc — với ứng dụng cần kết nối bền, hãy cấu hình dài hơn hoặc tự xử lý sự kiện onclose.

11.13.6 — Rà lại code của bạn ​

Danh sách rà soát bảo mật SignalR

  • Token qua query string chỉ chấp nhận cho đường dẫn hub.
  • Access log của proxy che giá trị access_token.
  • HTTP logging của ứng dụng không ghi query string.
  • Access token ngắn hạn để giảm cửa sổ rủi ro.
  • Hub có Authorize ở cấp lớp.
  • Phương thức nhạy cảm có Authorize riêng theo vai trò.
  • Hệ multi-tenant dùng group theo tenant, không dùng Clients.All.
  • IUserIdProvider khớp với claim thật trong token.
  • accessTokenFactory làm mới token khi sắp hết hạn.

Bài tập áp dụng ​

Bài 1 — Tìm access_token trong access log của proxy ​

Kết nối tới hub và tìm access_token trong access log của proxy. Cấu hình che rồi kiểm tra lại.

Tiêu chí hoàn thành: bạn che được ở proxy và nêu được vì sao che ở proxy vẫn chưa đủ.

Gợi ý và lời giải — Bài 1

Gợi ý. Access log của Nginx chỉ là một trong những nơi URL được ghi lại.

Lời giải — tìm token:

bash
sudo grep -o 'access_token=[^& ]*' /var/log/nginx/access.log | tail -1
text
access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4ZjNhMWIyYyIs...

Token nằm nguyên trong log vì API WebSocket của trình duyệt không cho đặt header tuỳ chỉnh, nên SignalR buộc phải đưa token vào query string:

js
// HTTP thông thường — gửi header được
fetch('/api/v1/leads', { headers: { Authorization: `Bearer ${token}` } });

// WebSocket — API trình duyệt KHÔNG cho gửi header tuỳ chỉnh
new WebSocket('wss://crm.company.com/hubs/notifications');
//            ^^ không có chỗ để đặt header

Che ở Nginx:

nginx
map $request_uri $masked_uri {
    ~^(?<path>[^?]*)\?(.*)access_token=[^&]*(.*)$  "$path?***";
    default                                         $request_uri;
}

log_format masked '$remote_addr - [$time_local] '
                  '"$request_method $masked_uri $server_protocol" $status';

location /hubs/ {
    access_log /var/log/nginx/access.log masked;
    proxy_pass http://crm;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
text
203.0.113.42 - [25/Sep/2026:09:20:11 +0700] "GET /hubs/notifications?*** HTTP/1.1" 101

Vì sao che ở proxy vẫn chưa đủ — token còn đi qua bốn nơi khác:

  1. Log của ứng dụng. Serilog request logging ghi lại query string theo mặc định:

    csharp
    app.UseSerilogRequestLogging(o =>
    {
        o.GetLevel = (ctx, _, _) =>
            ctx.Request.Path.StartsWithSegments("/hubs")
                ? LogEventLevel.Verbose
                : LogEventLevel.Information;
        // KHÔNG bao gồm RequestQuery
    });
  2. Application Insights, Datadog và mọi APM. Chúng ghi URL đầy đủ, và hệ thống giám sát thường có nhiều người truy cập hơn production.

  3. HAR file người dùng gửi khi báo lỗi. Xuất từ DevTools là kèm nguyên token.

  4. Log của CDN hoặc load balancer của nhà cung cấp, nơi bạn thường không đổi được định dạng log.

Vì vậy biện pháp thật sự hiệu quả là giảm thiệt hại, không phải che hết mọi nơi:

csharp
// Access token riêng cho việc mở kết nối, sống ngắn
expires: DateTime.UtcNow.AddMinutes(5)

Token lọt vào log thì cũng hết hạn trước khi ai kịp dùng. Cộng thêm việc chỉ chấp nhận token từ query string cho đúng đường dẫn hub:

csharp
options.Events = new JwtBearerEvents
{
    OnMessageReceived = ctx =>
    {
        var token = ctx.Request.Query["access_token"];
        // CHỈ cho endpoint hub — không áp dụng cho API thường
        if (!string.IsNullOrEmpty(token) &&
            ctx.HttpContext.Request.Path.StartsWithSegments("/hubs"))
            ctx.Token = token;
        return Task.CompletedTask;
    }
};

Thiếu điều kiện đường dẫn thì mọi API đều nhận token qua query string — và lúc đó lập trình viên frontend sẽ vô tình dùng nó ở nơi lẽ ra phải dùng header.


Bài 2 — Phân quyền theo vai trò trên Hub method ​

Gọi một phương thức hub có [Authorize(Roles = ...)] bằng tài khoản không có vai trò đó.

Tiêu chí hoàn thành: bạn nêu được client nhận được gì khi bị từ chối, và vì sao nó khác với 403 của HTTP.

Gợi ý và lời giải — Bài 2

Gợi ý. WebSocket không có mã trạng thái HTTP cho từng lời gọi. Vậy SignalR báo lỗi bằng cách nào?

Lời giải — Hub method có giới hạn vai trò:

csharp
[Authorize]
public class AdminHub : Hub<IAdminClient>
{
    [Authorize(Roles = "Admin")]
    public Task BroadcastMaintenance(string message)
        => Clients.All.MaintenanceNotice(message);
}
js
try {
    await connection.invoke('BroadcastMaintenance', 'Hệ thống bảo trì lúc 22h');
} catch (e) {
    console.log(e.message);
}
text
Failed to invoke 'BroadcastMaintenance' because user is unauthorized

Client nhận một exception, không phải mã trạng thái. Đây là khác biệt cần nêu: HTTP trả 403 trong header phản hồi, còn SignalR không có khái niệm đó cho từng lời gọi — nó gửi về một thông điệp lỗi, và thư viện client biến nó thành một Promise bị từ chối.

Hệ quả thực tế với client:

js
// HTTP — phân biệt được bằng mã số
if (res.status === 401) await refresh();
if (res.status === 403) showMessage('Không có quyền');

// SignalR — chỉ có chuỗi
catch (e) {
    // phải so khớp chuỗi, và chuỗi có thể đổi giữa các phiên bản
    if (e.message.includes('unauthorized')) showMessage('Không có quyền');
}

So khớp chuỗi là cách làm mong manh. Cách tốt hơn là tự ném HubException có mã thay vì dựa vào thông điệp mặc định:

csharp
public async Task BroadcastMaintenance(string message)
{
    if (!Context.User!.IsInRole("Admin"))
        throw new HubException("FORBIDDEN: Bạn không có quyền gửi thông báo bảo trì");

    await Clients.All.MaintenanceNotice(message);
}
js
catch (e) {
    const code = e.message.split(':')[0];
    if (code === 'FORBIDDEN') showMessage('Bạn không có quyền thực hiện thao tác này');
}

Nhớ dùng HubException chứ không phải exception thường — exception thường bị SignalR che thông điệp, như bài 11.3 đã chỉ ra.

Ba tầng phân quyền trong SignalR, và mỗi tầng chặn ở đâu:

Đặt ởChặn khiClient nhận
[Authorize] trên HubLúc kết nốiKhông kết nối được
[Authorize(Roles)] trên methodLúc gọi methodException
Kiểm tra trong thân methodLúc gọi, theo tài nguyênHubException do bạn viết

Tầng thứ ba là tầng không thể bỏ. Hai tầng đầu chỉ kiểm danh tính và vai trò; chúng không biết tham số của lời gọi. Một người có vai trò SalesRep vẫn gọi được JoinDealRoom(dealIdCuaNguoiKhac) nếu bạn không kiểm trong thân phương thức — đúng lỗ hổng ở bài 11.5.


Bài 3 — IUserIdProvider và nhiều tab ​

Gửi thông báo bằng Clients.User và xác nhận nó tới đúng người, kể cả khi mở nhiều tab.

Tiêu chí hoàn thành: bạn biết Context.UserIdentifier lấy giá trị từ đâu, và điều gì xảy ra khi claim đó không tồn tại.

Gợi ý và lời giải — Bài 3

Gợi ý. Nếu Clients.User("42") không gửi tới ai, câu hỏi đầu tiên là: SignalR đang so "42" với cái gì?

Lời giải — kiểm chứng nhiều tab:

csharp
// Gửi theo user id — SignalR tự tìm MỌI kết nối của người đó
await _hub.Clients.User(userId.ToString()).ReceiveNotification(dto);

Mở 3 tab cùng một tài khoản:

text
Tab 1: nhận thông báo
Tab 2: nhận thông báo
Tab 3: nhận thông báo

Cả ba đều nhận, vì SignalR giữ ánh xạ userId -> tập ConnectionId và tự gửi tới từng kết nối.

Context.UserIdentifier lấy từ đâu. Mặc định nó đọc claim ClaimTypes.NameIdentifier:

csharp
public override Task OnConnectedAsync()
{
    _logger.LogInformation("UserIdentifier = {Id}", Context.UserIdentifier ?? "(null)");
    return base.OnConnectedAsync();
}

Và đây là chỗ hay sai: handler JWT của .NET ánh xạ lại tên claim, biến sub trong token thành ClaimTypes.NameIdentifier — một URI dài của Microsoft. Nếu bạn đã tắt ánh xạ:

csharp
options.MapInboundClaims = false;

thì claim giữ nguyên tên sub, và ClaimTypes.NameIdentifier không còn tồn tại — Context.UserIdentifier trả null.

Điều gì xảy ra khi đó:

csharp
await _hub.Clients.User("8f3a1b2c").ReceiveNotification(dto);
text
(không exception, không log, không ai nhận được gì)

Lại là một lỗi hoàn toàn im lặng — cùng họ với tên nhóm lệch ở bài 11.6. SignalR tìm trong ánh xạ, không thấy khoá nào khớp, và kết thúc bình thường.

Sửa bằng một IUserIdProvider tường minh:

csharp
public sealed class UserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection) =>
        connection.User.FindFirstValue("sub")
        ?? connection.User.FindFirstValue(ClaimTypes.NameIdentifier);
}
csharp
builder.Services.AddSingleton<IUserIdProvider, UserIdProvider>();

Viết tường minh như vậy có hai cái lợi: nó hoạt động bất kể cấu hình MapInboundClaims, và nó đặt quyết định ở một chỗ đọc được thay vì phụ thuộc vào một mặc định ẩn.

Thêm một lớp bảo vệ — từ chối kết nối khi không xác định được người dùng:

csharp
public override async Task OnConnectedAsync()
{
    if (string.IsNullOrEmpty(Context.UserIdentifier))
    {
        _logger.LogError("Kết nối không xác định được UserIdentifier — kiểm tra IUserIdProvider");
        Context.Abort();
        return;
    }

    await Groups.AddToGroupAsync(Context.ConnectionId,
        HubGroups.Tenant(Context.User!.FindFirstValue("tenant_id")!));
    await base.OnConnectedAsync();
}

Thà đóng kết nối và ghi một dòng lỗi rõ ràng, còn hơn để hệ thống chạy trong trạng thái "kết nối thành công nhưng không bao giờ nhận được gì" — thứ tốn hàng giờ để gỡ.

Một bài test chặn lỗi này từ CI:

csharp
[Fact]
public async Task GuiTheoUserId_ToiMoiTabCuaNguoiDo()
{
    await using var c1 = await ConnectAsync(userId: "8f3a1b2c");
    await using var c2 = await ConnectAsync(userId: "8f3a1b2c");
    await using var c3 = await ConnectAsync(userId: "7c1d9e4f");   // người khác

    await _hub.Clients.User("8f3a1b2c").ReceiveNotification(dto);

    Assert.Equal(1, await c1.CountReceivedAsync());
    Assert.Equal(1, await c2.CountReceivedAsync());
    Assert.Equal(0, await c3.CountReceivedAsync());     // KHÔNG rò sang người khác
}

Dòng cuối quan trọng ngang hai dòng trên: nó bắt được trường hợp IUserIdProvider trả về một giá trị dùng chung cho mọi người, ví dụ khi claim bị null và bạn lỡ trả chuỗi rỗng thay vì null.

Tự kiểm tra ​

Vì sao SignalR phải đưa token vào query string?

Vì WebSocket API của trình duyệt không cho gửi header tuỳ chỉnh, nên không đính được header Authorization như lời gọi HTTP thông thường. Đây là hạn chế của chuẩn WebSocket, không phải của SignalR.

Vì sao điều kiện path StartsWithSegments quan trọng?

Không có nó, mọi API đều chấp nhận token qua query string, nên mọi lời gọi API cũng lộ token vào access log chứ không riêng kết nối hub.

Token trong query string bị ghi ở những đâu?

Access log của reverse proxy, log của CDN thường xuất ra hệ thống phân tích bên thứ ba, log của load balancer lưu trữ dài hạn, và lịch sử trình duyệt nếu URL bị mở trực tiếp.

Giảm nhẹ quan trọng nhất cho rủi ro này là gì?

Giữ access token ngắn hạn, khoảng 5 tới 15 phút, nên token trong log cũ hết hạn nhanh và cửa sổ rủi ro nhỏ. Che token trong log là lớp bổ sung.

Vì sao Clients.All gần như luôn sai trong hệ multi-tenant?

Vì nó gửi cho mọi công ty đang kết nối, tức rò rỉ dữ liệu giữa các tenant. Phải dùng group theo tenant và gửi tới đúng group.

Lỗi IUserIdProvider biểu hiện thế nào?

Clients.User không tìm thấy ai và thông báo biến mất im lặng, không có lỗi nào. Nguyên nhân thường là token dùng claim sub theo chuẩn JWT trong khi SignalR mặc định tìm ClaimTypes.NameIdentifier.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Token qua query string là cách chuẩn, nhưng chỉ cho phép trên đường dẫn hub.
  2. Query string vào access log — che nó, và giữ token ngắn hạn.
  3. Clients.All trong hệ multi-tenant là rò rỉ dữ liệu. Dùng group theo tenant.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.