feat(platform-service): licenses/activate issues JWT tokens + IP lockout
- /licenses/activate now enforces in-memory IP lockout window for failed attempts
- Device limit enforcement now reads from product config by plan (deviceLimits)
- Successful activation returns { license, accessToken, refreshToken }
- Re-activation on existing device also returns tokens
- Keeps existing license validity checks (status, expiry)
- Verified: tsc --noEmit clean, 19 test files / 178 tests passing
This commit is contained in:
parent
17772ed42a
commit
5e38342930
@ -11,6 +11,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getRequestProductId, getRequestProductConfig } from '../../lib/request-context.js';
|
||||
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
||||
import * as authRepo from '../auth/repository.js';
|
||||
import * as jwt from '../auth/jwt.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
GenerateLicenseSchema,
|
||||
@ -19,6 +21,37 @@ import {
|
||||
type LicenseDoc,
|
||||
} from './types.js';
|
||||
|
||||
const LOCKOUT_WINDOW_MS = Number(process.env.LICENSE_ACTIVATE_LOCKOUT_WINDOW_MS || 15 * 60 * 1000);
|
||||
const MAX_FAILED_ATTEMPTS = Number(process.env.LICENSE_ACTIVATE_MAX_FAILED_ATTEMPTS || 5);
|
||||
|
||||
const failedAttemptsByIp = new Map<string, { count: number; firstFailedAt: number }>();
|
||||
|
||||
function assertNotLockedOut(ip: string): void {
|
||||
const entry = failedAttemptsByIp.get(ip);
|
||||
if (!entry) return;
|
||||
if (Date.now() - entry.firstFailedAt > LOCKOUT_WINDOW_MS) {
|
||||
failedAttemptsByIp.delete(ip);
|
||||
return;
|
||||
}
|
||||
if (entry.count >= MAX_FAILED_ATTEMPTS) {
|
||||
throw new BadRequestError('Too many failed activation attempts. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
function recordActivationFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = failedAttemptsByIp.get(ip);
|
||||
if (!entry || now - entry.firstFailedAt > LOCKOUT_WINDOW_MS) {
|
||||
failedAttemptsByIp.set(ip, { count: 1, firstFailedAt: now });
|
||||
return;
|
||||
}
|
||||
failedAttemptsByIp.set(ip, { count: entry.count + 1, firstFailedAt: entry.firstFailedAt });
|
||||
}
|
||||
|
||||
function clearActivationFailures(ip: string): void {
|
||||
failedAttemptsByIp.delete(ip);
|
||||
}
|
||||
|
||||
export async function licenseRoutes(app: FastifyInstance) {
|
||||
// Generate license
|
||||
app.post('/licenses/generate', async (req, reply) => {
|
||||
@ -53,33 +86,77 @@ export async function licenseRoutes(app: FastifyInstance) {
|
||||
|
||||
// Activate
|
||||
app.post('/licenses/activate', async req => {
|
||||
assertNotLockedOut(req.ip);
|
||||
const parsed = ActivateLicenseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { key, deviceId } = parsed.data;
|
||||
const productId = getRequestProductId(req);
|
||||
const productConfig = getRequestProductConfig(req);
|
||||
const license = await repo.getByKey(key, productId);
|
||||
if (!license) throw new NotFoundError('License not found');
|
||||
if (license.status !== 'active') throw new BadRequestError('License is not active');
|
||||
if (!license) {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new NotFoundError('License not found');
|
||||
}
|
||||
if (license.status !== 'active') {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError('License is not active');
|
||||
}
|
||||
if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError('License has expired');
|
||||
}
|
||||
if (license.deviceIds.includes(deviceId)) {
|
||||
return license; // Already activated on this device
|
||||
const user = await authRepo.getById(license.userId);
|
||||
if (!user || user.status !== 'active') {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError('License owner account is missing or disabled');
|
||||
}
|
||||
clearActivationFailures(req.ip);
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
productId,
|
||||
plan: license.plan,
|
||||
});
|
||||
const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId });
|
||||
return { license, accessToken, refreshToken }; // Already activated on this device
|
||||
}
|
||||
if (license.deviceIds.length >= license.maxDevices) {
|
||||
const planDeviceLimit = productConfig.deviceLimits[license.plan] ?? license.maxDevices;
|
||||
if (license.deviceIds.length >= planDeviceLimit) {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError(
|
||||
`Maximum devices (${license.maxDevices}) reached. Deactivate a device first.`
|
||||
`Maximum devices (${planDeviceLimit}) reached for ${license.plan} plan. Deactivate a device first.`
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await repo.update(license.id, license.userId, {
|
||||
deviceIds: [...license.deviceIds, deviceId],
|
||||
maxDevices: planDeviceLimit,
|
||||
activatedAt: license.activatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
if (!updated) throw new NotFoundError('License update failed');
|
||||
return updated;
|
||||
|
||||
const user = await authRepo.getById(license.userId);
|
||||
if (!user || user.status !== 'active') {
|
||||
recordActivationFailure(req.ip);
|
||||
throw new BadRequestError('License owner account is missing or disabled');
|
||||
}
|
||||
|
||||
clearActivationFailures(req.ip);
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
productId,
|
||||
plan: updated.plan,
|
||||
});
|
||||
const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId });
|
||||
|
||||
return { license: updated, accessToken, refreshToken };
|
||||
});
|
||||
|
||||
// Deactivate device
|
||||
|
||||
Loading…
Reference in New Issue
Block a user