fix: harden tenant auth and quality gates
This commit is contained in:
@@ -21,6 +21,9 @@ export type SessionValidationResult =
|
||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||
|
||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
|
||||
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
|
||||
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
|
||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
||||
@@ -123,6 +126,61 @@ export class SessionService implements OnModuleDestroy {
|
||||
return this.client.del(this.key(token));
|
||||
}
|
||||
|
||||
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
|
||||
try {
|
||||
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async consumeCaptcha(captchaId: string) {
|
||||
try {
|
||||
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async isAnonymousLoginLocked(login: string) {
|
||||
try {
|
||||
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async recordAnonymousLoginFailure(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
|
||||
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
|
||||
try {
|
||||
const count = Number(await this.client.eval(
|
||||
`local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
|
||||
return count`,
|
||||
2,
|
||||
failureKey,
|
||||
lockKey,
|
||||
24 * 60 * 60,
|
||||
5,
|
||||
));
|
||||
return count;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async clearAnonymousLoginFailures(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
try {
|
||||
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
||||
}
|
||||
@@ -195,6 +253,10 @@ export class SessionService implements OnModuleDestroy {
|
||||
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
||||
}
|
||||
|
||||
private loginDigest(login: string) {
|
||||
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
|
||||
}
|
||||
|
||||
private get client() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
|
||||
Reference in New Issue
Block a user