+15

Xây dựng chức năng quản lý phiên đăng nhập trên nhiều thiết bị sử dụng NestJS và JWT

Trong bài viết trước, mình đã mô tả các lỗi bảo mật cơ bản phía Backend API qua các ví dụ cụ thể. Ở bài viết này, mình sẽ xây dựng một chức năng quản lý phiên đăng nhập trên nhiều thiết bị sử dụng JWT. Các hệ thống lớn như Google, Facebook... Một trong những điều kiện tiên quyết sống còn về bảo mật của một tài khoản phải có ít nhất những thứ sau đây. Đầu tiên là cảnh báo khi có thiết bị lạ đăng nhập dựa trên fingerprint như địa chỉ IP mới, deviceId mới. Điều thứ 2 là cũng là quan trọng nhất đó là quản lý được các phiên đăng nhập hiện hành mà tài khoản đó đang đăng nhập trên các thiết bị và tất nhiên là điều này sẽ đúng luôn với cả việc 1 ai đó sử dụng tài khoản của bạn trên một thiết bị khác, có thể do sơ hở của bạn mà đánh mất mật khẩu trên một trang phishing nào đó chẳng hạn.

Tuy nhiên khi đã sử dụng token để xác thực có nghĩa rằng phía client và phía server sẽ không lưu trữ trạng thái của nhau cho đến khi yêu cầu cuối cùng được client gửi lên server thông quan giao thức http/ https và gắn token trong header để server biết bạn là ai và đang sử dụng thiết bị nào.

Câu hỏi được đặt ra là "Điều gì sẽ xảy ra khi có sự tranh chấp tài khoản". Bởi vì các phiên đăng nhập hợp lệ được cấp dựa trên tài khoản và mật khẩu chính xác của một tài khoản được định danh duy nhất thông qua trường userId, và rõ ràng là user thì cũng có thể đăng nhập ở nhiều nơi và thậm chí người dùng đôi khi còn quên đăng xuất. Kể lại một chút câu chuyện ngày xưa hồi nhỏ đi chơi net, ông nào đi về mà quên thoát nick facebook ở quán net thì xác định luôn là bị đăng hoặc nhắn tin bậy bạ lên facebook 😃 Nhưng kẻ xấu thì chỉ có thể sử dụng phiên đó cho đến khi bị đăng xuất từ xa, hoặc ông nào vọc vạch một chút thì có thể export cái cookies ở máy đó rồi đem về nhà import vào máy mình và sử dụng tiếp cái nick kia. Cái trò này bây giờ các trang buff like, comment vẫn đang làm. Mình cũng có thể tạm gọi đây là trường hợp "Tài khoản đang trong quá trình tranh chấp".

Để xử lý vấn đề này thì mình sẽ chia làm 3 cấp độ ưu tiên để xác minh đâu là chủ tài khoản. Một là dựa trên việc có mật khẩu thật, tiếp theo nếu cả 2 cùng có mật khẩu thật thì sẽ tính đến các yếu tố như email, thiết bị thường xuyên đăng nhập, nếu bước 2 mà vẫn không thu hồi được tài khoản thì buộc khóa tài khoản và xác minh danh tính. Dưới đây mình sẽ không đi sâu về nghiệp vụ về bảo vệ tài khoản cho người dùng mà Google, Facebook đã làm, nhưng mình sẽ xây dựng cách mà quản lý phiên đăng nhập sử dụng JWT, làm sao để thu hồi 1 JWT đã cấp cho 1 device. Và code được mình viết trên NestJS

Để bắt đầu mình sẽ tóm tắt lại các phần mình sẽ làm như sau

  1. Xây dựng các bảng trong cơ sở dữ liệu liên quan đến quản lý user và device sessions. Quan hệ giữa user và device session là quan hệ một nhiều. Vì 1 user có thể đăng nhập nhiều thiết bị
  2. Xử lý logic đăng nhập
  3. Xử lý và lưu trữ phiên khi đăng nhập
  4. Đăng xuất trên một/ nhiều thiết bị
  5. ReAuth với refresh token

Mục tiêu của hệ thống này là quản lý được phiên đăng nhập. Một thiết bị đăng nhập hợp lệ được nhận dạng thông qua 4 yếu tố đó là: userId, deviceId, secretKey(=> token jwt), refresh token. Điều này giúp đảm bảo token đã cấp cho 1 thiết bị thì sẽ không thể đem sang thiết bị khác sử dụng được, đó cũng là 1 phần để tránh việc bị lộ token trong quá trình truyền tin.

Xây dựng 2 bảng cơ sở dữ liệu cho chức năng này bằng TypeORM

@Entity('user')
export class UserEntity {
  @PrimaryColumn({ type: 'uuid' })
  @Generated('uuid')
  id: string;
  @Column({ unique: true })
  email: string;
  @Column()
  password: string;
  @Column()
  salt: string;
  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt: Date;
  @UpdateDateColumn()
  updatedAt: Date;
  @OneToMany(() => DeviceSessionEntity, (deviceSessions) => deviceSessions.id)
  deviceSessions: DeviceSessionEntity[];

@Entity('device-session')
export class DeviceSessionEntity {
  @PrimaryColumn({ type: 'uuid' })
  @Generated('uuid')
  id: string;
  @Column({ unique: true })
  deviceId: string;
  @Column({ nullable: true })
  name: string;
  @Column()
  ua: string;
  @Column()
  secretKey: string;
  @Column()
  refreshToken: string;
  @Column()
  expiredAt: Date;
  @Column()
  ipAddress: string;
  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt: Date;
  @UpdateDateColumn()
  updatedAt: Date;
  @ManyToOne(() => UserEntity, (user) => user.id)
  user: string;
}

Xây dựng chức năng đăng nhập

Khi xác thực email và mật khẩu thành công, hệ thống tiến hành tạo một phiên đăng nhập mới và trả về cho client token, refreshToken và expiredAt

Đoạn controler đăng nhập này là tiền xử lý các body payload và meta data từ phía client gửi lên để nhận dạng thiết bị và người dùng đang gửi yêu cầu:

  @Post('login')
  async login(
    @Req() req,
    @Body() loginDto: LoginDto,
    @Headers() headers: Headers,
  ) {
    const fingerprint = req.fingerprint;
    const ipAddress = req.connection.remoteAddress;
    const ua = headers['user-agent'];
    const deviceId = fingerprint.hash;
    const metaData: LoginMetadata = { ipAddress, ua, deviceId };
    return this.usersService.login(loginDto, metaData);
  }

Khi validate đầy đủ các dữ liệu đầu vào, tiến trình sẽ gọi tiếp đến hàm service để xử lý email và mật khẩu người dùng gửi lên trong body payload:

 async login(loginDto: LoginDto, metaData: LoginMetadata) {
    const { email, password } = loginDto;
    const user = await this.repository.findOne({
      where: { email },
    });
    if (
      !user ||
      user.password !== (await this.hashPassword(password, user.salt))
    ) {
      throw new UnauthorizedException('Email or password incorect');
    } else {
      return await this.deviceSessionsService.handleDeviceSession(
        user.id,
        metaData,
      );
    }
  }

Khi xác thực đúng tài khoản mật khẩu thì sẽ đến phần tạo phiên đăng nhập bằng cách tạo 1 bản ghi trong bảng "device_sessions". Trong logic dưới đây đã kiểm tra xem deviceId đã tồn tại hay chưa. Nếu tồn tại rồi thì ghi đè lên phiên cũ với thời gian hết hạn mới là expiredAt = addDay(EXP_SESSION) còn không thì tạo mới bản ghi với 1 UUID ngẫu nhiên. Lưu ý 1 chút đoạn mình sử dụng UUID làm khóa chính cho bảng. Khác với ID tự tăng là phải khi nào ghi vào cơ sở dữ liệu mới sinh ra ID thì UUID có thể tạo ra trước khi ghi vào cơ sở dữ liệu vẫn được coi là hợp lệ. Ở đây mình đang cho cứ nếu thiết bị gọi yêu cầu đăng nhập thì đều cập nhật lại secret key và refresh token để đảm bảo tính toàn vẹn của phiên đăng nhập trước đó và cũng coi đó như là một bước để thu hồi token đã cấp.

  async handleDeviceSession(
    userId: string,
    metaData: LoginMetadata,
  ): Promise<LoginRespionse> {
    const { deviceId } = metaData;
    const currentDevice = await this.repository.findOne({
      where: { deviceId },
    });

    const expiredAt = addDay(EXP_SESSION);
    const secretKey = this.generateSecretKey();

    const payload = {
      id: userId,
      deviceId,
    };
    const [token, refreshToken] = [
      JwtStrategy.generate(payload, secretKey),
      randomatic('Aa0', 64),
    ];

    const deviceName = metaData.deviceId;
    const newDeviceSession = new DeviceSessionEntity();
    newDeviceSession.user = userId;
    newDeviceSession.secretKey = secretKey;
    newDeviceSession.refreshToken = refreshToken;
    newDeviceSession.expiredAt = expiredAt;
    newDeviceSession.deviceId = deviceId;
    newDeviceSession.ipAddress = metaData.ipAddress;
    newDeviceSession.ua = metaData.ua;
    newDeviceSession.name = deviceName;

    // update or create device session
    await this.repository.save({
      id: currentDevice?.id || randomUUID(),
      ...newDeviceSession,
    });
    return { token, refreshToken, expiredAt };
  }

image.png

Mình cũng có thêm 1 UserId decorator để lấy userId từ token ở các controler như sau.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtStrategy } from 'src/auth/guard/jwt.strategy';

export const UserId = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return JwtStrategy.getPayload(request.headers)['id'];
  },
);

Máy chủ nhận dạng thiết bị và token như thế nào

Trong khi code mình đã sử dụng 1 thư viện để lấy được mã fingerprint của request đó là express-fingerprint và sử dụng nó như một Middleware của NestJS. Điều này cho ta dễ dàng hơn khi có thể lấy được mã hash nhận dạng thiết bị thông qua request.fingerprint.hash

const Fingerprint = require('express-fingerprint');
  app.use(
    Fingerprint({
      parameters: [
        Fingerprint.useragent,
        Fingerprint.acceptHeaders,
        Fingerprint.geoip,
      ],
    }),
  );

Để đảm bảo được rằng token cấp cho thiết bị đang được sử dụng bởi chính thiết bị đó thì sẽ viết hàm Guard như sau:

export class JwtAuthGuard implements CanActivate {
  constructor(private reflector: Reflector, private authService: AuthService) {}

  private async validateRequest(request): Promise<boolean> {
    const headers = request.headers;
    const token = headers.authorization || null;
    if (!token) return false;
    const checkDeviceId = request.fingerprint.hash;
    const deviceId = JwtStrategy.getPayload(request.headers)['deviceId'];

    if (checkDeviceId !== deviceId) {
      throw new UnauthorizedException('Token not issued for this device');
    }
    try {
      const secretKey = await this.authService.getSecretKey(request);
      return !!JwtStrategy.verify(token, secretKey);
    } catch (e) {
      return false;
    }
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    if (this.reflector.get(keyPublicRoute, context.getHandler())) return true;

    const request = context.switchToHttp().getRequest();

    if (!(await this.validateRequest(request))) {
      throw new UnauthorizedException();
    }
    return true;
  }
}

Một mặt chúng ta lấy deviceId từ fingerprint và so sánh với deviceId ở trong token. Điều này giúp chắc chắn rằng phiên đăng nhập này không bị đánh cắp và sử dụng ở thiết bị khác, bởi vì như đã nói ở trên, deviceId và secret key sinh ra token là một cặp ngẫu nhiên khớp nhau cho mỗi phiên trên một thiết bị.

Lấy những phiên đã đăng nhập

Hàm này thì khá cơ bản, chủ yếu chỉ là lấy userId để truy vấn vào database lấy ra các phiên đã đăng nhập của user đó

async getDeviceSessions(userId: string) {
    return this.repository.find({
      where: {
        user: userId,
      },
      select: [
        'id',
        'deviceId',
        'createdAt',
        'ipAddress',
        'name',
        'ua',
        'expiredAt',
        'updatedAt',
      ],
    });
  }

image.png

Cấp lại token bằng phương thức reAuth với refresh token

Cũng như các hệ thống khác, mình cấp token có thời hạn rất ngắn, thường chỉ tính bằng phút và một refresh token có thời hạn khoảng 1 tuần. Điều này cũng là 1 cách để đảm bảo an toàn thông tin cho hệ thống xác thực của Backend API. Điều cần làm là gửi refresh token lên và hệ thống sẽ kiểm tra xem refresh token này trong cơ sở dữ liệu có đúng là của thiết bị đã được cấp trước đó hay không. Cụ thể code như sau:

async reAuth(
    deviceId: string,
    _refreshToken: string,
  ): Promise<LoginRespionse> {
    const session: any = await this.repository
      .createQueryBuilder('session')
      .select('session', 'user.id')
      .leftJoinAndSelect('session.user', 'user')
      .where('session.refreshToken = :_refreshToken', { _refreshToken })
      .andWhere('session.deviceId = :deviceId', { deviceId })
      .getOne();

    if (
      !session ||
      new Date(session.expiredAt).valueOf() < new Date().valueOf()
    ) {
      throw new UnauthorizedException('Refresh token invalid');
    }

    const payload = {
      id: session.user.id,
      deviceId,
    };

    const secretKey = this.generateSecretKey();
    const [token, refreshToken, expiredAt] = [
      JwtStrategy.generate(payload, secretKey),
      randomatic('Aa0', 64),
      addDay(7),
    ];

    await this.repository.update(session.id, {
      secretKey,
      refreshToken,
      expiredAt,
    });
    return { token, refreshToken, expiredAt };
  }

Về bản chất thì hàm reAuth sẽ trả về các thông tin như một hàm login. Và cũng lần lượt các bước kiểm tra thông tin deviceId và refresh token, thời gian hợp lệ. Khi token mới được cấp thì cũng tiến hành cập nhật secret key mới và refresh token mới vào trong database. image.png

Đăng xuất một thiết bị chỉ định

Và chúng ta có thêm một hàm để xử lý việc đăng xuất một thiết bị chỉ định thông qua sessionIduserId. Trong đoạn code dưới đây thực ra chỉ cần sessionId là đã có thể xóa được phiên rồi. Tuy nhiên để đảm bảo về an toàn bảo mật thì cần phải có thêm userId nữa để tránh bị lỗi IDOR mà user khác có thể gửi request chéo. Có nghĩa là chỉ tự đăng xuất các thiết bị trên chính tài khoản của mình. Điều này cũng đúng với việc đăng xuất trên nhiều thiết bị.

  async logout(userId: string, sessionId: string) {
    const session: any = await this.repository
      .createQueryBuilder('session')
      .leftJoinAndSelect('session.user', 'user')
      .select(['session', 'user.id'])
      .where('session.id = :sessionId', { sessionId })
      .getOne();

    if (!session || session.user.id !== userId) {
      throw new ForbiddenException();
    }
    const keyCache = this.authService.getKeyCache(userId, session.deviceId);

    await this.cacheManager.set(keyCache, null);
    await this.repository.delete(sessionId);
    return {
      message: 'Logout success',
      status: 200,
      sessionId,
    };
  }

Cách làm trên đã vi phạm vào 1 điều kiện tuyệt vời của JWT

JWT sinh ra là để giúp cho việc xác thực không cần truy vấn database. Mỗi lần xác thực chỉ cần cầm token và secret key là xong. Tuy nhiên cách trên đã phải lưu secret key vào database. Điều đó đồng nghĩa rằng tất cả các request mà có xác thực thì đều phải truy vấn database hay sao? Hmm, không phải như thế mà sẽ có hướng giải quyết khác bằng cách lưu secret key vào Memory Cache với TTL bằng thời gian sống của Token. Cách làm này giúp giảm được phần lớn query vào database chỉ để lấy token. Và nó cũng sẽ bị mất đi khi token hết hạn, khi user call refresh token để cấp lại 1 token mới với secret key mới thì hệ thống lại tạo 1 value cache tương ứng với key là sk_${userId}_${deviceId}

@Injectable()
export default class AuthService {
  constructor(
    @Inject(CACHE_MANAGER)
    private cacheManager: Cache,
    @InjectRepository(DeviceSessionEntity)
    private deviceSessionsRepository: Repository<DeviceSessionEntity>,
  ) {}

  async getSecretKey(request): Promise<string> {
    const headers = request.headers;
    const payload = JwtStrategy.decode(headers.authorization);
    const { deviceId, id, exp } = payload;

    const keyCache = this.getKeyCache(id, deviceId);
    const secretKeyFromCache: string = await this.cacheManager.get(keyCache);

    if (secretKeyFromCache) return secretKeyFromCache;

    const { secretKey } = await this.deviceSessionsRepository
      .createQueryBuilder('deviceSessions')
      .where('deviceSessions.deviceId = :deviceId', { deviceId })
      .andWhere('deviceSessions.userId = :id', { id })
      .getOne();

    await this.cacheManager.set(
      keyCache,
      secretKey,
      (exp - Math.floor(Date.now() / 1000)) * 1000,
    );
    return secretKey;
  }

  getKeyCache(userId, deviceId): string {
    return `sk_${userId}_${deviceId}`;
  }
}

Kết luận

Trên đây là code minh họa về chức năng quản lý phiên đăng nhập trên nhiều thiết bị. Đó là code cơ bản nhất và trên thực tế thì có thể phức tạp hơn. Vì vậy code ví dụ chỉ mang tính tham khảo không nên sử dụng khi chưa được kiểm thử kỹ càng. Cảm ơn các bạn đã đọc bài của mình. Hẹn gặp lại các bạn trong các bài viết sau trong các bài viết về Secure Coding sắp tới.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí