我們在 AWS 挑戰中建立了多租戶資料庫架構,今天我們要從軟體開發角度實作 Auth Service,這是整個健身房 SaaS 平台的核心服務。
我們需要建立一個支援多種認證方式、權限控制,並且安全的認證系統。
在健身房 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; // 分鐘
};
}
// 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];
}
}
// 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);
}
}
// 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);
}
}
// 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
});
}
};
}
};
}
// 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
};
});
}
// 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) => {
// 軟刪除用戶
});
}
// 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);
});
});
});
// 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 機制
✅ 租戶隔離: 完整的多租戶認證流程
✅ 審計追蹤: 完整的認證事件日誌