iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 11

Day 11: 30天打造SaaS產品軟體開發篇-Auth Service 多租戶認證與 RBAC 系統

  • 分享至 

  • xImage
  •  

前情提要

我們在 AWS 挑戰中建立了多租戶資料庫架構,今天我們要從軟體開發角度實作 Auth Service,這是整個健身房 SaaS 平台的核心服務。

我們需要建立一個支援多種認證方式、權限控制,並且安全的認證系統。

Auth Service 架構設計

認證授權需求分析

在健身房 SaaS 系統中,我們面對多種角色:

// packages/kyo-types/src/auth.ts
export enum UserRole {
  // 平台管理員
  PLATFORM_ADMIN = 'platform_admin',

  // 健身房管理角色
  GYM_OWNER = 'gym_owner',       // 老闆
  GYM_MANAGER = 'gym_manager',   // 店長
  GYM_STAFF = 'gym_staff',       // 櫃台人員

  // 健身房使用者角色
  TRAINER = 'trainer',           // 教練
  MEMBER = 'member',             // 學員
  GUEST = 'guest'                // 訪客
}

export enum Permission {
  // 會員管理
  MEMBER_CREATE = 'member:create',
  MEMBER_READ = 'member:read',
  MEMBER_UPDATE = 'member:update',
  MEMBER_DELETE = 'member:delete',

  // 教練管理
  TRAINER_CREATE = 'trainer:create',
  TRAINER_READ = 'trainer:read',
  TRAINER_UPDATE = 'trainer:update',
  TRAINER_DELETE = 'trainer:delete',

  // 課程管理
  COURSE_CREATE = 'course:create',
  COURSE_READ = 'course:read',
  COURSE_UPDATE = 'course:update',
  COURSE_DELETE = 'course:delete',
  COURSE_BOOK = 'course:book',

  // 會籍管理
  MEMBERSHIP_CREATE = 'membership:create',
  MEMBERSHIP_READ = 'membership:read',
  MEMBERSHIP_UPDATE = 'membership:update',
  MEMBERSHIP_DELETE = 'membership:delete',

  // 帳務管理
  BILLING_READ = 'billing:read',
  BILLING_MANAGE = 'billing:manage',

  // 系統管理
  SYSTEM_CONFIG = 'system:config',
  SYSTEM_ANALYTICS = 'system:analytics'
}

多元認證策略

// packages/kyo-types/src/auth.ts
export interface AuthStrategy {
  type: 'email_password' | 'phone_otp' | 'line_login';
  isEnabled: boolean;
  config?: Record<string, any>;
}

export interface TenantAuthConfig {
  tenantId: string;
  strategies: AuthStrategy[];
  securityPolicy: {
    passwordComplexity: boolean;
    twoFactorRequired: boolean;
    sessionTimeout: number; // 分鐘
    maxFailedAttempts: number;
    lockoutDuration: number; // 分鐘
  };
}

🔐 Auth Service 核心實作

1. 認證服務基礎架構

// apps/kyo-auth-service/src/services/authService.ts
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcrypt';

const LoginSchema = z.object({
  strategy: z.enum(['email_password', 'phone_otp', 'line_login']),
  tenantId: z.string().uuid(),
  credentials: z.object({
    email: z.string().email().optional(),
    password: z.string().min(6).optional(),
    phone: z.string().optional(),
    otpCode: z.string().optional(),
    lineCode: z.string().optional(),
  }),
  deviceInfo: z.object({
    userAgent: z.string(),
    ipAddress: z.string(),
    deviceId: z.string().optional(),
  })
});

export class AuthService {
  constructor(
    private db: DatabaseConnection,
    private otpService: OtpService,
    private lineService: LineAuthService
  ) {}

  async authenticate(data: z.infer<typeof LoginSchema>) {
    const validatedData = LoginSchema.parse(data);

    // 檢查租戶狀態
    const tenant = await this.validateTenant(validatedData.tenantId);
    if (!tenant || tenant.status !== 'active') {
      throw new Error('Tenant not available');
    }

    // 根據認證策略進行驗證
    let user: User;
    switch (validatedData.strategy) {
      case 'email_password':
        user = await this.authenticateEmailPassword(validatedData);
        break;
      case 'phone_otp':
        user = await this.authenticatePhoneOtp(validatedData);
        break;
      case 'line_login':
        user = await this.authenticateLineLogin(validatedData);
        break;
    }

    // 檢查用戶狀態和安全策略
    await this.validateUserSecurity(user, tenant.securityPolicy);

    // 生成 Token
    const tokens = await this.generateTokens(user, tenant);

    // 記錄登入事件
    await this.logAuthEvent('login_success', user, validatedData.deviceInfo);

    return {
      user: this.sanitizeUser(user),
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      expiresIn: tokens.expiresIn
    };
  }

  private async authenticateEmailPassword(data: any) {
    const { email, password, tenantId } = data.credentials;

    const user = await this.db.query(`
      SELECT u.*, ur.role, ur.permissions
      FROM users u
      LEFT JOIN user_roles ur ON u.id = ur.user_id
      WHERE u.tenant_id = $1 AND u.email = $2 AND u.status = 'active'
    `, [tenantId, email]);

    if (!user.rows[0]) {
      throw new Error('Invalid credentials');
    }

    const userData = user.rows[0];

    // 驗證密碼
    const isValidPassword = await bcrypt.compare(password, userData.password_hash);
    if (!isValidPassword) {
      await this.handleFailedLogin(userData.id, 'invalid_password');
      throw new Error('Invalid credentials');
    }

    return userData;
  }

  private async authenticatePhoneOtp(data: any) {
    const { phone, otpCode, tenantId } = data.credentials;

    // 驗證 OTP
    const otpResult = await this.otpService.verify({
      phone,
      code: otpCode,
      tenantId
    });

    if (!otpResult.success) {
      throw new Error('Invalid OTP code');
    }

    // 查詢或建立用戶
    let user = await this.db.query(`
      SELECT u.*, ur.role, ur.permissions
      FROM users u
      LEFT JOIN user_roles ur ON u.id = ur.user_id
      WHERE u.tenant_id = $1 AND u.phone = $2
    `, [tenantId, phone]);

    if (!user.rows[0]) {
      // 自動建立新用戶 (適用於會員首次登入)
      user = await this.createUserFromPhone(tenantId, phone);
    }

    return user.rows[0];
  }

  private async authenticateLineLogin(data: any) {
    const { lineCode, tenantId } = data.credentials;

    // 透過 LINE Login API 取得用戶資料
    const lineUser = await this.lineService.getUserProfile(lineCode);

    // 查詢或建立用戶
    let user = await this.db.query(`
      SELECT u.*, ur.role, ur.permissions
      FROM users u
      LEFT JOIN user_roles ur ON u.id = ur.user_id
      WHERE u.tenant_id = $1 AND u.line_user_id = $2
    `, [tenantId, lineUser.userId]);

    if (!user.rows[0]) {
      // 自動建立 LINE 用戶
      user = await this.createUserFromLine(tenantId, lineUser);
    }

    return user.rows[0];
  }
}

2. JWT Token 管理

// apps/kyo-auth-service/src/services/tokenService.ts
export class TokenService {
  private jwtSecret: Uint8Array;
  private refreshSecret: Uint8Array;

  constructor() {
    this.jwtSecret = new TextEncoder().encode(
      process.env.JWT_SECRET || 'your-secret-key'
    );
    this.refreshSecret = new TextEncoder().encode(
      process.env.JWT_REFRESH_SECRET || 'your-refresh-secret'
    );
  }

  async generateAccessToken(user: User, tenant: Tenant): Promise<string> {
    const payload = {
      sub: user.id,
      tenantId: tenant.id,
      role: user.role,
      permissions: user.permissions || [],
      email: user.email,
      name: user.name,
      type: 'access'
    };

    return await new SignJWT(payload)
      .setProtectedHeader({ alg: 'HS256' })
      .setIssuedAt()
      .setExpirationTime('15m') // 短時間有效
      .sign(this.jwtSecret);
  }

  async generateRefreshToken(user: User, tenant: Tenant): Promise<string> {
    const payload = {
      sub: user.id,
      tenantId: tenant.id,
      type: 'refresh'
    };

    return await new SignJWT(payload)
      .setProtectedHeader({ alg: 'HS256' })
      .setIssuedAt()
      .setExpirationTime('7d') // 長時間有效
      .sign(this.refreshSecret);
  }

  async verifyAccessToken(token: string) {
    try {
      const { payload } = await jwtVerify(token, this.jwtSecret);

      if (payload.type !== 'access') {
        throw new Error('Invalid token type');
      }

      return payload as JWTPayload & {
        sub: string;
        tenantId: string;
        role: string;
        permissions: string[];
      };
    } catch (error) {
      throw new Error('Invalid access token');
    }
  }

  async verifyRefreshToken(token: string) {
    try {
      const { payload } = await jwtVerify(token, this.refreshSecret);

      if (payload.type !== 'refresh') {
        throw new Error('Invalid token type');
      }

      return payload as JWTPayload & {
        sub: string;
        tenantId: string;
      };
    } catch (error) {
      throw new Error('Invalid refresh token');
    }
  }

  async refreshAccessToken(refreshToken: string) {
    const payload = await this.verifyRefreshToken(refreshToken);

    // 查詢最新的用戶資料
    const user = await this.getUserById(payload.sub);
    const tenant = await this.getTenantById(payload.tenantId);

    if (!user || !tenant || user.status !== 'active') {
      throw new Error('User or tenant no longer available');
    }

    // 生成新的 access token
    return await this.generateAccessToken(user, tenant);
  }
}

3. RBAC 權限系統

// apps/kyo-auth-service/src/services/rbacService.ts
export class RBACService {
  constructor(private db: DatabaseConnection) {}

  async assignRole(userId: string, tenantId: string, role: UserRole) {
    // 取得角色預設權限
    const defaultPermissions = this.getDefaultPermissions(role);

    await this.db.query(`
      INSERT INTO user_roles (tenant_id, user_id, role, permissions)
      VALUES ($1, $2, $3, $4)
      ON CONFLICT (tenant_id, user_id, role)
      DO UPDATE SET permissions = EXCLUDED.permissions
    `, [tenantId, userId, role, JSON.stringify(defaultPermissions)]);
  }

  private getDefaultPermissions(role: UserRole): Permission[] {
    const permissionMap: Record<UserRole, Permission[]> = {
      [UserRole.PLATFORM_ADMIN]: Object.values(Permission),

      [UserRole.GYM_OWNER]: [
        Permission.MEMBER_CREATE, Permission.MEMBER_READ, Permission.MEMBER_UPDATE, Permission.MEMBER_DELETE,
        Permission.TRAINER_CREATE, Permission.TRAINER_READ, Permission.TRAINER_UPDATE, Permission.TRAINER_DELETE,
        Permission.COURSE_CREATE, Permission.COURSE_READ, Permission.COURSE_UPDATE, Permission.COURSE_DELETE,
        Permission.MEMBERSHIP_CREATE, Permission.MEMBERSHIP_READ, Permission.MEMBERSHIP_UPDATE, Permission.MEMBERSHIP_DELETE,
        Permission.BILLING_READ, Permission.BILLING_MANAGE,
        Permission.SYSTEM_CONFIG, Permission.SYSTEM_ANALYTICS
      ],

      [UserRole.GYM_MANAGER]: [
        Permission.MEMBER_CREATE, Permission.MEMBER_READ, Permission.MEMBER_UPDATE,
        Permission.TRAINER_READ, Permission.TRAINER_UPDATE,
        Permission.COURSE_CREATE, Permission.COURSE_READ, Permission.COURSE_UPDATE,
        Permission.MEMBERSHIP_READ, Permission.MEMBERSHIP_UPDATE,
        Permission.BILLING_READ,
        Permission.SYSTEM_ANALYTICS
      ],

      [UserRole.GYM_STAFF]: [
        Permission.MEMBER_READ, Permission.MEMBER_UPDATE,
        Permission.TRAINER_READ,
        Permission.COURSE_READ, Permission.COURSE_BOOK,
        Permission.MEMBERSHIP_READ
      ],

      [UserRole.TRAINER]: [
        Permission.MEMBER_READ,
        Permission.COURSE_READ, Permission.COURSE_UPDATE, // 只能更新自己的課程
        Permission.SYSTEM_ANALYTICS // 查看自己的數據
      ],

      [UserRole.MEMBER]: [
        Permission.COURSE_READ, Permission.COURSE_BOOK,
        Permission.MEMBERSHIP_READ
      ],

      [UserRole.GUEST]: [
        Permission.COURSE_READ
      ]
    };

    return permissionMap[role] || [];
  }

  async checkPermission(
    userId: string,
    tenantId: string,
    requiredPermission: Permission
  ): Promise<boolean> {
    const userRoles = await this.db.query(`
      SELECT permissions FROM user_roles
      WHERE tenant_id = $1 AND user_id = $2
    `, [tenantId, userId]);

    for (const role of userRoles.rows) {
      const permissions = role.permissions as Permission[];
      if (permissions.includes(requiredPermission) || permissions.includes('*' as Permission)) {
        return true;
      }
    }

    return false;
  }

  async getUserPermissions(userId: string, tenantId: string): Promise<Permission[]> {
    const userRoles = await this.db.query(`
      SELECT permissions FROM user_roles
      WHERE tenant_id = $1 AND user_id = $2
    `, [tenantId, userId]);

    const allPermissions = new Set<Permission>();

    for (const role of userRoles.rows) {
      const permissions = role.permissions as Permission[];
      permissions.forEach(p => allPermissions.add(p));
    }

    return Array.from(allPermissions);
  }
}

4. 安全中間件

// apps/kyo-auth-service/src/middleware/authMiddleware.ts
export function createAuthMiddleware(rbacService: RBACService) {
  return {
    // JWT Token 驗證
    async authenticate(request: FastifyRequest, reply: FastifyReply) {
      const authHeader = request.headers.authorization;

      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return reply.status(401).send({ error: 'Missing or invalid authorization header' });
      }

      const token = authHeader.slice(7);

      try {
        const tokenService = new TokenService();
        const payload = await tokenService.verifyAccessToken(token);

        // 設定請求上下文
        request.user = {
          id: payload.sub,
          tenantId: payload.tenantId,
          role: payload.role,
          permissions: payload.permissions
        };

        // 設定資料庫租戶上下文
        await setTenantContext(request.pg, payload.tenantId, payload.role);

      } catch (error) {
        return reply.status(401).send({ error: 'Invalid or expired token' });
      }
    },

    // 權限檢查
    requirePermission(permission: Permission) {
      return async (request: FastifyRequest, reply: FastifyReply) => {
        if (!request.user) {
          return reply.status(401).send({ error: 'Authentication required' });
        }

        const hasPermission = await rbacService.checkPermission(
          request.user.id,
          request.user.tenantId,
          permission
        );

        if (!hasPermission) {
          return reply.status(403).send({
            error: 'Insufficient permissions',
            required: permission
          });
        }
      };
    },

    // 角色檢查
    requireRole(roles: UserRole[]) {
      return async (request: FastifyRequest, reply: FastifyReply) => {
        if (!request.user) {
          return reply.status(401).send({ error: 'Authentication required' });
        }

        if (!roles.includes(request.user.role as UserRole)) {
          return reply.status(403).send({
            error: 'Insufficient role',
            required: roles,
            current: request.user.role
          });
        }
      };
    }
  };
}

🔌 API 路由設計

1. 認證相關 API

// apps/kyo-auth-service/src/routes/auth.ts
export default async function authRoutes(fastify: FastifyInstance) {
  const authService = new AuthService(fastify.pg, fastify.otp, fastify.line);
  const rbacService = new RBACService(fastify.pg);
  const authMiddleware = createAuthMiddleware(rbacService);

  // 登入
  fastify.post('/login', {
    schema: {
      body: LoginSchema,
      response: {
        200: {
          type: 'object',
          properties: {
            user: { type: 'object' },
            accessToken: { type: 'string' },
            refreshToken: { type: 'string' },
            expiresIn: { type: 'number' }
          }
        }
      }
    }
  }, async (request, reply) => {
    try {
      const result = await authService.authenticate(request.body);
      return result;
    } catch (error) {
      return reply.status(400).send({ error: error.message });
    }
  });

  // 登出
  fastify.post('/logout', {
    preHandler: [authMiddleware.authenticate]
  }, async (request, reply) => {
    // 將 token 加入黑名單
    await authService.revokeToken(request.headers.authorization!);
    return { message: 'Logged out successfully' };
  });

  // 重新整理 Token
  fastify.post('/refresh', {
    schema: {
      body: {
        type: 'object',
        properties: {
          refreshToken: { type: 'string' }
        },
        required: ['refreshToken']
      }
    }
  }, async (request, reply) => {
    try {
      const tokenService = new TokenService();
      const newAccessToken = await tokenService.refreshAccessToken(
        request.body.refreshToken
      );
      return { accessToken: newAccessToken, expiresIn: 900 }; // 15 minutes
    } catch (error) {
      return reply.status(401).send({ error: error.message });
    }
  });

  // 取得使用者資訊
  fastify.get('/me', {
    preHandler: [authMiddleware.authenticate]
  }, async (request, reply) => {
    const permissions = await rbacService.getUserPermissions(
      request.user!.id,
      request.user!.tenantId
    );

    return {
      id: request.user!.id,
      tenantId: request.user!.tenantId,
      role: request.user!.role,
      permissions
    };
  });
}

2. 用戶管理 API

// apps/kyo-auth-service/src/routes/users.ts
export default async function userRoutes(fastify: FastifyInstance) {
  const authMiddleware = createAuthMiddleware(new RBACService(fastify.pg));

  // 建立用戶
  fastify.post('/users', {
    preHandler: [
      authMiddleware.authenticate,
      authMiddleware.requirePermission(Permission.MEMBER_CREATE)
    ],
    schema: {
      body: {
        type: 'object',
        properties: {
          name: { type: 'string' },
          email: { type: 'string', format: 'email' },
          phone: { type: 'string' },
          role: { type: 'string', enum: Object.values(UserRole) }
        },
        required: ['name']
      }
    }
  }, async (request, reply) => {
    // 建立用戶邏輯
  });

  // 更新用戶
  fastify.put('/users/:id', {
    preHandler: [
      authMiddleware.authenticate,
      authMiddleware.requirePermission(Permission.MEMBER_UPDATE)
    ]
  }, async (request, reply) => {
    // 更新用戶邏輯
  });

  // 停用用戶
  fastify.delete('/users/:id', {
    preHandler: [
      authMiddleware.authenticate,
      authMiddleware.requirePermission(Permission.MEMBER_DELETE)
    ]
  }, async (request, reply) => {
    // 軟刪除用戶
  });
}

🧪 測試策略

1. 認證流程測試

// apps/kyo-auth-service/src/__tests__/auth.test.ts
describe('Auth Service', () => {
  let app: FastifyInstance;
  let testTenantId: string;

  beforeAll(async () => {
    app = await buildApp();
    testTenantId = await createTestTenant();
  });

  describe('Email/Password Authentication', () => {
    it('should authenticate valid user', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/api/auth/login',
        payload: {
          strategy: 'email_password',
          tenantId: testTenantId,
          credentials: {
            email: 'test@gym.com',
            password: 'password123'
          },
          deviceInfo: {
            userAgent: 'test-agent',
            ipAddress: '127.0.0.1'
          }
        }
      });

      expect(response.statusCode).toBe(200);
      const body = JSON.parse(response.body);
      expect(body.accessToken).toBeDefined();
      expect(body.refreshToken).toBeDefined();
      expect(body.user.email).toBe('test@gym.com');
    });

    it('should reject invalid credentials', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/api/auth/login',
        payload: {
          strategy: 'email_password',
          tenantId: testTenantId,
          credentials: {
            email: 'test@gym.com',
            password: 'wrongpassword'
          },
          deviceInfo: {
            userAgent: 'test-agent',
            ipAddress: '127.0.0.1'
          }
        }
      });

      expect(response.statusCode).toBe(400);
    });
  });

  describe('Permission System', () => {
    it('should allow gym owner to create members', async () => {
      const loginResponse = await loginAsGymOwner(app, testTenantId);
      const { accessToken } = JSON.parse(loginResponse.body);

      const response = await app.inject({
        method: 'POST',
        url: '/api/users',
        headers: {
          authorization: `Bearer ${accessToken}`
        },
        payload: {
          name: 'New Member',
          email: 'newmember@example.com',
          role: 'member'
        }
      });

      expect(response.statusCode).toBe(201);
    });

    it('should deny member from creating other members', async () => {
      const loginResponse = await loginAsMember(app, testTenantId);
      const { accessToken } = JSON.parse(loginResponse.body);

      const response = await app.inject({
        method: 'POST',
        url: '/api/users',
        headers: {
          authorization: `Bearer ${accessToken}`
        },
        payload: {
          name: 'New Member',
          email: 'newmember2@example.com',
          role: 'member'
        }
      });

      expect(response.statusCode).toBe(403);
    });
  });
});

📊 監控與日誌

1. 認證事件追蹤

// apps/kyo-auth-service/src/services/auditService.ts
export class AuditService {
  constructor(private db: DatabaseConnection) {}

  async logAuthEvent(
    event: 'login_success' | 'login_failed' | 'logout' | 'token_refresh',
    userId: string,
    tenantId: string,
    metadata: Record<string, any>
  ) {
    await this.db.query(`
      INSERT INTO auth_audit_logs (
        tenant_id, user_id, event_type, metadata, created_at
      ) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
    `, [tenantId, userId, event, JSON.stringify(metadata)]);
  }

  async getFailedLoginAttempts(email: string, tenantId: string, hours: number = 1) {
    const result = await this.db.query(`
      SELECT COUNT(*) as attempts
      FROM auth_audit_logs
      WHERE tenant_id = $1
        AND JSON_EXTRACT_PATH_TEXT(metadata, 'email') = $2
        AND event_type = 'login_failed'
        AND created_at > CURRENT_TIMESTAMP - INTERVAL '${hours} hours'
    `, [tenantId, email]);

    return parseInt(result.rows[0].attempts);
  }
}

🎯 今日成果

今天我們完成了 Kyo-System Auth Service 的核心功能:

多元認證系統: Email/Password、Phone OTP、LINE Login 三合一
細緻權限控制: RBAC 系統支援角色與權限管理
安全 Token 管理: JWT + Refresh Token 機制
租戶隔離: 完整的多租戶認證流程
審計追蹤: 完整的認證事件日誌


上一篇
Day 10: 30天打造SaaS產品後端篇-Monorepo 架構與開發專案總結
下一篇
Day 12:30天打造SaaS產品軟體開發篇-健身房會員服務與查詢系統
系列文
30 天打造工作室 SaaS 產品 (後端篇)16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言