Friday, August 23, 2019

Angular JWT Authorization with Refresh Token and Http Interceptor

Bảo mật ứng dụng một trang là một phần rất quan trọng trong quá trình thực hiện, nhưng đôi khi nó mang lại nhiều sự nhầm lẫn, đặc biệt là khi có nhiều cách để đạt được nó. Trong bài viết này, tôi sẽ tập trung vào cách tiếp cận sử dụng JSON Web Tokens (JWT) như một cơ chế để truyền đạt quyền của người dùng. Hơn nữa, tôi sẽ trình bày những lợi ích và những cạm bẫy tiềm tàng của bảo mật dựa trên JWT.
Angular JWT Authorization with Refresh Token and Http Interceptor

Trong bài viết này, bạn sẽ tìm hiểu:

Cách hạn chế quyền truy cập vào các phần nhất định của ứng dụng Angular, sử dụng Bộ định tuyến Bộ định tuyến cách chặn cuộc gọi HTTP, thêm Mã thông báo truy cập theo yêu cầu của máy chủ
tại sao chúng tôi cần Mã thông báo làm mới và cách sử dụng nó một cách minh bạch cho người dùng

Mục lục


  1. Cài đặt ứng dụng
  2. Bộ định tuyến
  3. Mã thông báo web JSON
  4. Đánh chặn http
  5. Làm mới mã thông báo
  6. Dịch vụ xác thực
  7. Tóm lược

Cài đặt ứng dụng
Chúng ta hãy nghĩ về trường hợp sử dụng phổ biến trong đó có một số trang (tuyến đường) trong ứng dụng mà quyền truy cập chỉ bị hạn chế cho người dùng được ủy quyền. Sau khi xác thực thành công ,
ví dụ thông qua một hình thức đăng nhập, người dùng được cấp quyền truy cập vào một số phần bị hạn chế của hệ thống (ví dụ: trang quản trị).
Xác thực là quá trình chứng minh danh tính của một người. Nếu chúng ta nói về hình thức đăng nhập, chúng tôi giả sử rằng nếu một người sở hữu mật khẩu được liên kết với tên người dùng cụ thể, thì đó phải là người mà tên người dùng này thuộc về.
Việc ủy ​​quyền xảy ra sau khi xác thực thành công và xác định xem người dùng cụ thể có được phép truy cập các tài nguyên đã cho hay không (ví dụ: các trang con trong SPA).
Để đơn giản, hãy giả sử rằng chúng tôi có một ứng dụng có trang đăng nhập, có sẵn theo /loginlộ trình và một trang hiển thị một số ngẫu nhiên được tạo bởi máy chủ, có sẵn trong /secret-random-number. Trang số ngẫu nhiên chỉ có sẵn cho người dùng được ủy quyền. Nếu chúng tôi cố gắng truy cập theo cách thủ công, /secret-random-number chúng tôi sẽ được chuyển hướng trở lại trang đăng nhập.

Bộ định tuyến
Để đạt được mục tiêu hạn chế quyền truy cập /secret-random-number và chuyển hướng trở lại trang đăng nhập, trong trường hợp người dùng chưa đăng nhập, chúng tôi có thể sử dụng cơ chế tích hợp sẵn của Angular được gọi Router Guards. Những người bảo vệ này cho phép chúng tôi thực hiện các chính sách điều chỉnh chuyển tuyến có thể có trong ứng dụng Angular. Hãy tưởng tượng một tình huống khi người dùng cố gắng mở một trang mà anh ta không có quyền truy cập. Trong trường hợp ứng dụng như vậy không nên cho phép chuyển tuyến này. Để đạt được mục tiêu này, chúng ta có thể sử dụng CanActivate bảo vệ. Vì Router Guards chỉ là các nhà cung cấp lớp đơn giản, chúng ta cần thực hiện một giao diện thích hợp. Chúng ta hãy xem trình bày đoạn mã dưới đây AuthGuard.
@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) { }
  canActivate() {
    if (this.authService.isLoggedIn()) {
      this.router.navigate(['/secret-random-number']);
    }
    return !this.authService.isLoggedIn();
  }
}

AuthGuard thực hiện canActivate() cho bộ định tuyến Angular xem nó có thể hoặc không thể kích hoạt một tuyến cụ thể. Để gắn bảo vệ cho tuyến đường cần bảo vệ, chúng ta chỉ cần đặt tham chiếu của nó vào thuộc canActivate tính của tuyến đường đó như được trình bày dưới đây. Trong trường hợp của chúng tôi, chúng tôi muốn bảo vệ /login tuyến đường. Chúng tôi muốn cho phép người dùng mở tuyến đường này, chỉ khi họ chưa đăng nhập. Nếu không, chúng tôi chuyển hướng đến /secret-random-number.
Cách tiếp cận tương tự áp dụng để bảo vệ các tuyến khác, với các chính sách khác nhau được thực hiện cho các tuyến đã cho. Ngoài ra, chúng ta có thể nhận thấy canLoad tài sản trong cấu hình tuyến đường dưới đây. Loại bảo vệ này cho phép chúng tôi ngăn chặn tuyến đường tải lười biếng được tải từ máy chủ. Thông thường, canLoad lính canh thực hiện chính sách giống như canActivatelính canh.
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/login' },
  {
    path: 'login',
    component: LoginComponent,
    canActivate: [AuthGuard]
  },
  {
    path: 'secret-random-number',
    loadChildren: './random/random.module#RandomModule',
    canActivate: [RandomGuard],
    canLoad: [RandomGuard]
  }
];
@NgModule({
  imports: [
    RouterModule.forRoot(routes)
  ],
  exports: [RouterModule],
  declarations: []
})
export class AppRoutingModule { }

Mã thông báo web JSON
Chúng tôi đã đến điểm mà chúng tôi đã bảo đảm các tuyến đường trong ứng dụng của chúng tôi. Bước tiếp theo là suy nghĩ về các yêu cầu HTTP mà ứng dụng gửi đến máy chủ. Nếu chúng tôi chỉ ngăn người dùng thực hiện các hành động bị cấm trong ứng dụng của mình, chúng tôi sẽ vẫn dễ bị các cuộc gọi HTTP trái phép có thể được thực thi bởi người dùng, ví dụ như với bất kỳ máy khách HTTP nào khác. Do đó, điều quan trọng hơn trong việc bảo mật ứng dụng web là đảm bảo rằng các yêu cầu máy chủ trái phép không được phép. Để giúp máy chủ có thể nhận ra nếu yêu cầu đến từ người dùng được ủy quyền, chúng tôi có thể đính kèm một tiêu đề HTTP bổ sung cho biết thực tế đó. Đây là nơi mà JSON Web Tokens (JWT) phát huy tác dụng.
Ý tưởng chung đứng đằng sau JWT là truyền thông tin an toàn giữa các bên. Trong trường hợp của chúng tôi, đó là danh tính của người dùng cùng với quyền của anh ta, được truyền giữa máy khách (trình duyệt) và máy chủ. Khi người dùng đăng nhập, gửi truy vấn đăng nhập đến máy chủ, anh ta sẽ nhận lại JWT (còn gọi là mã thông báo truy cập) được ký bởi máy chủ bằng khóa riêng . Khóa riêng này chỉ được biết đến máy chủ vì nó cho phép máy chủ sau đó xác minh rằng mã thông báo là hợp pháp. Khi JWT được truyền giữa trình duyệt và máy chủ, nó được mã hóa bằng thuật toán Base64, điều đó làm cho nó trông giống như một chuỗi các ký tự ngẫu nhiên (không có gì có thể hơn từ sự thật!). Nếu bạn lấy JWT và giải mã nó bằng Base64, bạn sẽ tìm thấy một đối tượng JSON. Dưới đây bạn có thể tìm thấy nội dung được giải mã của JWT từ ứng dụng ví dụ của chúng tôi. Trên jwt.io bạn có thể chơi với JWT trực tuyến.

Mỗi JWT bao gồm 3 khối: tiêu đề , tải trọng và chữ ký . Các tiêu đề xác định loại của token và các thuật toán được sử dụng. Các tải trọng là nơi mà chúng tôi đưa dữ liệu chúng tôi muốn truyền tải một cách an toàn. Trong trường hợp này, chúng tôi có tên người dùng, vai trò, phát hành dấu thời gian (iat) và dấu thời gian hết hạn (exp). Khối cuối cùng (chức năng HMACSHA256) là một chữ ký được tạo bằng thuật toán HMAC và SHA-256 . Các chữ ký đảm bảo không chỉ có vậy token đã được tạo ra bởi một bên thứ tiếng, mà còn là của mã thông báo toàn vẹn .

Tính toàn vẹn là sự đảm bảo tính chính xác và nhất quán của dữ liệu trong suốt vòng đời của nó. Trong trường hợp mã thông báo JWT, điều đó có nghĩa là nó đã không bị thay đổi trong quá trình truyền.
{
  "alg":"HS256",
  "typ":"JWT"
}
{
  "username": "user",
  "role": "admin",
  "iat": 1556172533,
  "exp": 1556173133
}
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  SECRET!
)
Khi người dùng đăng nhập thành công vào ứng dụng và nhận được mã thông báo truy cập, ứng dụng phải được duy trì bằng cách nào đó. Chúng tôi có thể sử dụng ví dụ lưu trữ cục bộ của trình duyệt để lưu mã thông báo đó. Nó khá thuận tiện và dễ thực hiện, nhưng nó dễ bị tấn công XSS . Một cách tiếp cận khác có thể là sử dụng Cookie HTTPOnly được coi là an toàn hơn so với lưu trữ cục bộ. Khi chúng tôi vẫn tồn tại JWT, chúng tôi sẽ đính kèm nó vào các yêu cầu gửi đi trong HTTP Header. Trước khi chúng ta đi sâu vào khía cạnh đó, chúng ta hãy xem xét một đặc điểm quan trọng khác của JWT.
Tại thời điểm này, đáng để xem xét kỹ hơn bản chất khép kín của JWT. Khi máy chủ nhận được các yêu cầu HTTP với Mã thông báo truy cập JWT, nó không phải yêu cầu bất kỳ lớp lưu giữ lâu dài nào (ví dụ cơ sở dữ liệu) để xác minh quyền của người dùng. Những quyền đó nằm trong mã thông báo. Và vì chúng tôi đảm bảo tính xác thực và tính toàn vẹn của Mã thông báo truy cập, chúng tôi có thể tin tưởng vào thông tin bên trong nó. Đây là một tính năng thực sự thú vị của JWT vì nó mở ra cơ hội cho khả năng mở rộng cao hơn của hệ thống. Các kịch bản thay thế sẽ yêu cầu lưu một số id phiên ở phía phụ trợ và yêu cầu nó mỗi lần có nhu cầu cho phép yêu cầu. Có khép kínMã thông báo truy cập, chúng tôi không phải sao chép mã thông báo giữa các cụm máy chủ hoặc triển khai các phiên dính .

Đánh chặn http
Khi chúng tôi vẫn tồn tại Mã thông báo truy cập (JWT) sau khi người dùng đăng nhập vào ứng dụng, chúng tôi muốn sử dụng nó để ủy quyền cho các yêu cầu gửi đi. Một cách tiếp cận có thể đơn giản là cập nhật mọi dịch vụ giao tiếp với API để làm phong phú thêm các yêu cầu với Tiêu đề HTTP bổ sung. Điều này sẽ dẫn đến rất nhiều mã trùng lặp so với cách tiếp cận với HTTP Interceptor. Mục tiêu của HTTP Interceptor là áp dụng một số logic xử lý cho mọi yêu cầu gửi đi trong ứng dụng.
Tạo một bộ chặn HTTP khá giống với việc tạo Bộ bảo vệ Bộ định tuyến. Chúng ta cần phải có một lớp thực hiện một giao diện cụ thể với phương thức cần thiết. Trong trường hợp này, đó là HttpInterceptor với intercept phương pháp. Hãy xem đoạn mã sau với phần chặn từ ứng dụng ví dụ của chúng tôi. Đầu tiên, chúng tôi muốn kiểm tra xem mã thông báo có khả dụng hay không this.authService.getJwtToken(). Nếu chúng tôi có mã thông báo, chúng tôi sẽ đặt tiêu đề HTTP phù hợp. Mã này cũng chứa logic xử lý lỗi, sẽ được mô tả sau trong bài viết này.

@Injectable()
export class TokenInterceptor implements HttpInterceptor {
  constructor(public authService: AuthService) { }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (this.authService.getJwtToken()) {
      request = this.addToken(request, this.authService.getJwtToken());
    }
    return next.handle(request).pipe(catchError(error => {
      if (error instanceof HttpErrorResponse && error.status === 401) {
        return this.handle401Error(request, next);
      } else {
        return throwError(error);
      }
    }));
  }
  private addToken(request: HttpRequest<any>, token: string) {
    return request.clone({
      setHeaders: {
        'Authorization': `Bearer ${token}`
      }
    });
  }
}
Đã thực hiện đánh chặn của chúng tôi, cần phải đăng ký nó như một nhà cung cấp với HTTP_INTERCEPTORSmã thông báo trong mô-đun Angular.
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
@NgModule({
  // declarations...
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: TokenInterceptor,
      multi: true
    }
  ]
  // imports...
})
export class AuthModule { }
Làm mới mã Token
Có tính đến việc JWT khép kín, chúng ta cần suy nghĩ về một điều nữa - không có cách nào để vô hiệu hóa nó! Nếu ai đó khác ngoài chúng tôi sở hữu mã thông báo, chúng tôi có thể làm rất ít về nó. Đó là lý do tại sao nên luôn cung cấp cho mã thông báo thời gian hiệu lực ngắn. Không có quy tắc nghiêm ngặt nào về việc một mã thông báo sẽ tồn tại bao lâu và nó phụ thuộc vào yêu cầu hệ thống. Điểm khởi đầu tốt có thể có mã thông báo chỉ có hiệu lực trong 15 phút. Sau thời gian đó, máy chủ sẽ không coi mã thông báo này hợp lệ và sẽ không cho phép các yêu cầu với nó.
Vì vậy, đây là một thách thức khác - chúng tôi không muốn buộc người dùng đăng nhập vào ứng dụng, giả sử cứ sau 15 phút. Giải pháp cho vấn đề này là a Refresh Token. Loại mã thông báo này tồn tại ở đâu đó ở phía máy chủ (cơ sở dữ liệu, bộ nhớ cache trong bộ nhớ, v.v.) và được liên kết với phiên của người dùng cụ thể. Điều quan trọng cần lưu ý là mã thông báo này khác với JWT theo nhiều cách. Đầu tiên, nó không khép kín - nó có thể đơn giản như một chuỗi ngẫu nhiên duy nhất. Thứ hai, chúng ta cần lưu trữ nó để có thể xác minh xem phiên của người dùng có còn tồn tại không. Điều này cho chúng ta khả năng vô hiệu hóa phiên bằng cách xóa cặp liên kết[user, refresh_token]. Khi có một yêu cầu đến với Mã thông báo truy cập đã trở nên không hợp lệ, ứng dụng có thể gửi Mã thông báo làm mới để nhận Mã thông báo truy cập mới. Nếu phiên của người dùng vẫn còn tồn tại, máy chủ sẽ phản hồi với JWT hợp lệ mới. Trong ví dụ của chúng tôi, chúng tôi sẽ gửi Mã thông báo làm mới một cách minh bạch cho người dùng, để anh ta không biết về quy trình làm mới.

Hãy quay trở lại đánh chặn của chúng tôi. Nếu bạn nhớ từ đoạn mã trước, trong trường hợp Lỗi HTTP 401 (Không được phép), chúng tôi có một phương pháp đặc biệt handle401Error để xử lý tình huống này. Đây là một phần khó khăn - chúng tôi muốn xếp hàng tất cả các yêu cầu HTTP trong trường hợp làm mới. Điều này có nghĩa là nếu máy chủ phản hồi với Lỗi 401, chúng tôi muốn bắt đầu làm mới, chặn tất cả các yêu cầu có thể xảy ra trong quá trình làm mới và giải phóng chúng sau khi làm mới xong. Để có thể chặn và giải phóng các yêu cầu trong quá trình làm mới, chúng tôi sẽ sử dụng BehaviorSubject như một semaphore .
Đầu tiên, chúng tôi kiểm tra xem việc làm mới chưa bắt đầu và đặt isRefreshing biến thành đúng và điền null vào refreshTokenSubject chủ đề hành vi. Sau đó, yêu cầu làm mới thực tế bắt đầu. Trong trường hợp thành công, isRefreshingđược đặt thành false và mã thông báo JWT nhận được được đặt vào refreshTokenSubject. Cuối cùng, chúng tôi gọi next.handlevới addTokenphương thức để nói với người chặn rằng chúng tôi đã hoàn thành việc xử lý yêu cầu này. Trong trường hợp việc làm mới đã xảy ra (phần khác của câu lệnh if), chúng tôi muốn đợi cho đến khi refreshTokenSubjectchứa giá trị khác với null. Sử dụng filter(token => token != null)sẽ làm cho thủ thuật này! Khi có một số giá trị khác với null (chúng tôi mong đợi JWT mới bên trong), chúng tôi gọi take(1)để hoàn thành luồng. Cuối cùng, chúng ta có thể yêu cầu người đánh chặn hoàn thành việc xử lý yêu cầu này next.handle.
private isRefreshing = false;
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
  if (!this.isRefreshing) {
    this.isRefreshing = true;
    this.refreshTokenSubject.next(null);
    return this.authService.refreshToken().pipe(
      switchMap((token: any) => {
        this.isRefreshing = false;
        this.refreshTokenSubject.next(token.jwt);
        return next.handle(this.addToken(request, token.jwt));
      }));
  } else {
    return this.refreshTokenSubject.pipe(
      filter(token => token != null),
      take(1),
      switchMap(jwt => {
        return next.handle(this.addToken(request, jwt));
      }));
  }
}
Như bạn thấy, sự kết hợp của Mã thông báo truy cập và Mã thông báo làm mới là sự đánh đổi giữa khả năng mở rộng và bảo mật. Hạn chế thời gian hiệu lực của Mã thông báo truy cập sẽ giảm rủi ro cho một người không mong muốn sử dụng nó, nhưng sử dụng Mã thông báo làm mới đòi hỏi phải có trạng thái trên máy chủ.

Dịch vụ xác thực
Phần còn thiếu cuối cùng của giải pháp của chúng tôi là AuthService. Đây sẽ là nơi chúng tôi thực hiện tất cả logic để xử lý đăng nhập và đăng xuất. Dưới đây bạn có thể tìm thấy nguồn của dịch vụ đó và chúng tôi sẽ phân tích từng bước.

Hãy bắt đầu với login phương pháp. Ở đây chúng tôi sử dụng HttpClient để thực hiện cuộc gọi bài đến máy chủ và áp dụng một số toán tử với pipe()phương thức. Bằng cách sử dụng tap()toán tử, chúng tôi có thể thực hiện các hiệu ứng phụ mong muốn . Khi thực hiện phương pháp bài thành công, chúng ta sẽ nhận được Mã thông báo truy cập và Mã thông báo làm mới. Tác dụng phụ mà chúng tôi muốn thực hiện là lưu trữ các cuộc gọi mã thông báo này doLoginUser. Trong ví dụ này, chúng tôi sử dụng lưu trữ cục bộ. Sau khi được lưu trữ, giá trị trong luồng được ánh xạ thành đúng để người tiêu dùng của luồng đó biết rằng hoạt động đã thành công. Cuối cùng, trong trường hợp có lỗi, chúng tôi hiển thị cảnh báo và trả về có thể quan sát được là sai.
Tác dụng phụ là một thuật ngữ được sử dụng trong Lập trình chức năng. Khái niệm này trái ngược với độ tinh khiết chức năng , có nghĩa là không có thay đổi trạng thái trong hệ thống và chức năng luôn trả về kết quả dựa trên các đầu vào của nó (bất kể trạng thái hệ thống). Nếu có thay đổi trạng thái (ví dụ thay đổi biến), chúng tôi gọi đó là tác dụng phụ .

Việc thực hiện logoutphương pháp về cơ bản là giống nhau, ngoài thực tế, bên trong cơ thể của yêu cầu chúng tôi gửi refreshToken. Điều này sẽ được sử dụng bởi máy chủ để xác định ai đang cố gắng đăng xuất. Sau đó, máy chủ sẽ xóa cặp [user, refresh_token]và làm mới sẽ không thể thực hiện được nữa. Tuy nhiên, Mã thông báo truy cập vẫn sẽ có hiệu lực cho đến khi hết hạn, nhưng chúng tôi xóa nó khỏi bộ lưu trữ cục bộ.
@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private readonly JWT_TOKEN = 'JWT_TOKEN';
  private readonly REFRESH_TOKEN = 'REFRESH_TOKEN';
  private loggedUser: string;
  constructor(private http: HttpClient) {}
  login(user: { username: string, password: string }): Observable<boolean> {
    return this.http.post<any>(`${config.apiUrl}/login`, user)
      .pipe(
        tap(tokens => this.doLoginUser(user.username, tokens)),
        mapTo(true),
        catchError(error => {
          alert(error.error);
          return of(false);
        }));
  }
  logout() {
    return this.http.post<any>(`${config.apiUrl}/logout`, {
      'refreshToken': this.getRefreshToken()
    }).pipe(
      tap(() => this.doLogoutUser()),
      mapTo(true),
      catchError(error => {
        alert(error.error);
        return of(false);
      }));
  }
  isLoggedIn() {
    return !!this.getJwtToken();
  }
  refreshToken() {
    return this.http.post<any>(`${config.apiUrl}/refresh`, {
      'refreshToken': this.getRefreshToken()
    }).pipe(tap((tokens: Tokens) => {
      this.storeJwtToken(tokens.jwt);
    }));
  }
  getJwtToken() {
    return localStorage.getItem(this.JWT_TOKEN);
  }
  private doLoginUser(username: string, tokens: Tokens) {
    this.loggedUser = username;
    this.storeTokens(tokens);
  }
  private doLogoutUser() {
    this.loggedUser = null;
    this.removeTokens();
  }
  private getRefreshToken() {
    return localStorage.getItem(this.REFRESH_TOKEN);
  }
  private storeJwtToken(jwt: string) {
    localStorage.setItem(this.JWT_TOKEN, jwt);
  }
  private storeTokens(tokens: Tokens) {
    localStorage.setItem(this.JWT_TOKEN, tokens.jwt);
    localStorage.setItem(this.REFRESH_TOKEN, tokens.refreshToken);
  }
  private removeTokens() {
    localStorage.removeItem(this.JWT_TOKEN);
    localStorage.removeItem(this.REFRESH_TOKEN);
  }
}
Tóm lược
Chúng tôi đã đề cập đến những phần quan trọng nhất của việc thiết kế một cơ chế ủy quyền ở phía trước trong Angular. Bạn có thể tìm thấy các nguồn đầy đủ của phía trước và phía sau trong kho GitHub:
https://github.com/bartosz-io/jwt-auth-angular
https://github.com/bartosz-io/jwt-auth-node
Sử dụng JWT làm Mã thông báo truy cập có rất nhiều lợi ích và việc thực hiện khá đơn giản. Tuy nhiên, bạn nên biết về những hạn chế và các cuộc tấn công XSS có thể xảy ra. Cách để giảm thiểu rủi ro là sử dụng Cookies HTTPOnly để lưu trữ mã thông báo.

Nếu bạn thích bài viết này, vui lòng chia sẻ nó trên phương tiện truyền thông xã hội hoặc để lại nhận xét, vì vậy tôi biết rằng nó rất hữu ích. Bạn cũng được chào đón nhiều hơn khi tham gia Angular
Nếu bạn quan tâm đến nhiều tài liệu liên quan đến Angular hơn,

No comments:

Post a Comment