fa0fa78312
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
|
import { ErrorCode } from "@drift/contracts";
|
|
import { Prisma, type Account } from "@prisma/client";
|
|
import { randomInt, randomUUID } from "node:crypto";
|
|
import { DomainException } from "../common/domain.exception.js";
|
|
import { PrismaService } from "../database/prisma.service.js";
|
|
import { RedisService } from "../redis/redis.service.js";
|
|
import {
|
|
codeHmac,
|
|
demoSmsCodeEnabled,
|
|
encryptPhone,
|
|
envInt,
|
|
phoneHmac,
|
|
refreshHmac,
|
|
} from "./auth.config.js";
|
|
import { TokenService } from "./token.service.js";
|
|
|
|
export interface TokenPair {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresIn: number;
|
|
}
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
|
@Inject(RedisService) private readonly redis: RedisService,
|
|
@Inject(TokenService) private readonly tokens: TokenService,
|
|
) {}
|
|
normalizePhone(raw: string): string {
|
|
const compact = raw.replace(/[\s()-]/g, "");
|
|
const local = compact.startsWith("+86")
|
|
? compact.slice(3)
|
|
: compact.startsWith("86") && compact.length === 13
|
|
? compact.slice(2)
|
|
: compact;
|
|
if (!/^1[3-9]\d{9}$/.test(local))
|
|
throw new DomainException(
|
|
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
|
"Invalid credentials",
|
|
HttpStatus.UNAUTHORIZED,
|
|
);
|
|
return `+86${local}`;
|
|
}
|
|
private prefix() {
|
|
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
|
}
|
|
private async limitAll(
|
|
dimensions: Array<{
|
|
kind: string;
|
|
parts: string[];
|
|
max: number;
|
|
ttl: number;
|
|
}>,
|
|
): Promise<void> {
|
|
await this.redis.ensureConnected();
|
|
const keys = dimensions.map(
|
|
({ kind, parts }) => `${this.prefix()}limit:${kind}:${parts.join(":")}`,
|
|
);
|
|
const args = dimensions.flatMap(({ max, ttl }) => [
|
|
String(max),
|
|
String(ttl),
|
|
]);
|
|
// Atomic across all dimensions on the MVP's single Redis node. Redis Cluster
|
|
// would require a colocated hash tag or a different distributed limiter.
|
|
const result = Number(
|
|
await this.redis.client.eval(
|
|
`for i,key in ipairs(KEYS) do local n=tonumber(redis.call('GET',key) or '0'); local max=tonumber(ARGV[(i-1)*2+1]); if n+1>max then return 0 end end; for i,key in ipairs(KEYS) do local n=redis.call('INCR',key); if n==1 then redis.call('EXPIRE',key,ARGV[(i-1)*2+2]) end end; return 1`,
|
|
{ keys, arguments: args },
|
|
),
|
|
);
|
|
if (result !== 1)
|
|
throw new DomainException(
|
|
ErrorCode.RATE_LIMITED,
|
|
"Too many requests",
|
|
HttpStatus.TOO_MANY_REQUESTS,
|
|
);
|
|
}
|
|
async sendSms(
|
|
rawPhone: string,
|
|
deviceId: string,
|
|
ip: string,
|
|
): Promise<{ sent: true; debugCode?: string }> {
|
|
const phone = this.normalizePhone(rawPhone);
|
|
const digest = phoneHmac(phone);
|
|
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
|
|
await this.limitAll([
|
|
{
|
|
kind: "send-phone",
|
|
parts: [digest],
|
|
max: envInt("SMS_SEND_LIMIT", 3),
|
|
ttl: window,
|
|
},
|
|
{
|
|
kind: "send-device",
|
|
parts: [deviceId],
|
|
max: envInt("SMS_SEND_DEVICE_LIMIT", 5),
|
|
ttl: window,
|
|
},
|
|
{
|
|
kind: "send-ip",
|
|
parts: [phoneHmac(ip)],
|
|
max: envInt("SMS_SEND_IP_LIMIT", 10),
|
|
ttl: window,
|
|
},
|
|
]);
|
|
const code = randomInt(0, 1000000).toString().padStart(6, "0");
|
|
await this.redis.client.set(
|
|
`${this.prefix()}code:${digest}`,
|
|
codeHmac(digest, code),
|
|
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
|
|
);
|
|
const result: { sent: true; debugCode?: string } = { sent: true };
|
|
if (demoSmsCodeEnabled()) result.debugCode = code;
|
|
return result;
|
|
}
|
|
async login(
|
|
rawPhone: string,
|
|
code: string,
|
|
deviceId: string,
|
|
ip: string,
|
|
): Promise<TokenPair> {
|
|
const phone = this.normalizePhone(rawPhone);
|
|
const digest = phoneHmac(phone);
|
|
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
|
|
await this.limitAll([
|
|
{
|
|
kind: "verify-phone",
|
|
parts: [digest],
|
|
max: envInt("SMS_VERIFY_LIMIT", 5),
|
|
ttl: window,
|
|
},
|
|
{
|
|
kind: "verify-combined",
|
|
parts: [digest, phoneHmac(ip), deviceId],
|
|
max: envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
|
|
ttl: window,
|
|
},
|
|
]);
|
|
const key = `${this.prefix()}code:${digest}`;
|
|
const consumed = await this.redis.client.eval(
|
|
`local v=redis.call('GET',KEYS[1]); if v and v==ARGV[1] then redis.call('DEL',KEYS[1]); return 1 end; return 0`,
|
|
{ keys: [key], arguments: [codeHmac(digest, code)] },
|
|
);
|
|
if (Number(consumed) !== 1) throw this.invalid();
|
|
let account = await this.prisma.account.findUnique({
|
|
where: { phoneHmac: digest },
|
|
});
|
|
if (!account) account = await this.createAccount(phone, digest);
|
|
if (account.status !== "ACTIVE") throw this.invalid();
|
|
const now = new Date();
|
|
const sanction = await this.prisma.sanction.findFirst({
|
|
where: {
|
|
accountId: account.id,
|
|
type: { in: ["SUSPENSION", "BAN"] },
|
|
revokedAt: null,
|
|
startsAt: { lte: now },
|
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (sanction) throw this.invalid();
|
|
return this.createSession(account, deviceId);
|
|
}
|
|
private async createAccount(phone: string, digest: string): Promise<Account> {
|
|
try {
|
|
return await this.prisma.account.create({
|
|
data: {
|
|
phoneHmac: digest,
|
|
phoneCiphertext: new Uint8Array(encryptPhone(phone)),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === "P2002"
|
|
) {
|
|
const found = await this.prisma.account.findUnique({
|
|
where: { phoneHmac: digest },
|
|
});
|
|
if (found) return found;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
private async createSession(
|
|
account: Account,
|
|
deviceId: string,
|
|
): Promise<TokenPair> {
|
|
const id = randomUUID();
|
|
const refresh = this.tokens.newRefresh();
|
|
const expiresAt = new Date(
|
|
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
|
|
);
|
|
await this.prisma.session.create({
|
|
data: {
|
|
id,
|
|
accountId: account.id,
|
|
tokenVersion: account.tokenVersion,
|
|
deviceId,
|
|
refreshTokenHash: refreshHmac(refresh),
|
|
tokenFamily: id,
|
|
expiresAt,
|
|
refreshTokens: {
|
|
create: { tokenHash: refreshHmac(refresh), generation: 0, expiresAt },
|
|
},
|
|
},
|
|
});
|
|
return this.pair(account, id, deviceId, refresh);
|
|
}
|
|
private pair(
|
|
account: Account,
|
|
sessionId: string,
|
|
deviceId: string,
|
|
refreshToken: string,
|
|
): TokenPair {
|
|
const expiresIn = envInt("ACCESS_TOKEN_TTL_SECONDS", 900);
|
|
return {
|
|
accessToken: this.tokens.issueAccess({
|
|
sub: account.id,
|
|
session_id: sessionId,
|
|
device_id: deviceId,
|
|
scopes: ["user"],
|
|
token_version: account.tokenVersion,
|
|
}),
|
|
refreshToken,
|
|
expiresIn,
|
|
};
|
|
}
|
|
async refresh(
|
|
token: string,
|
|
ip: string,
|
|
retryAttempt = 0,
|
|
): Promise<TokenPair> {
|
|
if (!this.validRefreshShape(token)) throw this.invalid();
|
|
if (retryAttempt === 0)
|
|
await this.limitAll([
|
|
{
|
|
kind: "refresh-attempt-ip",
|
|
parts: [phoneHmac(ip)],
|
|
max: envInt("REFRESH_ATTEMPT_IP_LIMIT", 30),
|
|
ttl: envInt("REFRESH_ATTEMPT_IP_WINDOW_SECONDS", 60),
|
|
},
|
|
]);
|
|
const hash = refreshHmac(token);
|
|
const observed = await this.prisma.refreshToken.findUnique({
|
|
where: { tokenHash: hash },
|
|
include: { session: true },
|
|
});
|
|
if (!observed) throw this.invalid();
|
|
if (retryAttempt === 0) {
|
|
await this.limitAll([
|
|
{
|
|
kind: "refresh-token",
|
|
parts: [hash],
|
|
max: envInt("REFRESH_RATE_LIMIT", 60),
|
|
ttl: 60,
|
|
},
|
|
{
|
|
kind: "refresh-session",
|
|
parts: [observed.sessionId],
|
|
max: envInt("REFRESH_RATE_LIMIT", 60),
|
|
ttl: 60,
|
|
},
|
|
]);
|
|
await this.cleanupExpiredRefreshTokens(observed.sessionId);
|
|
}
|
|
if (observed.status === "USED")
|
|
return this.revokeReusedFamily(observed.session.tokenFamily);
|
|
|
|
try {
|
|
const result = await this.prisma.$transaction(
|
|
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
|
|
const stored = await tx.refreshToken.findUnique({
|
|
where: { tokenHash: hash },
|
|
include: { session: { include: { account: true } } },
|
|
});
|
|
if (!stored) throw this.invalid();
|
|
if (stored.status === "USED")
|
|
return { reusedFamily: stored.session.tokenFamily };
|
|
const session = stored.session;
|
|
if (session.tokenVersion !== session.account.tokenVersion)
|
|
return { reusedFamily: session.tokenFamily };
|
|
if (
|
|
session.revokedAt ||
|
|
session.expiresAt <= new Date() ||
|
|
stored.expiresAt <= new Date() ||
|
|
session.account.status !== "ACTIVE"
|
|
)
|
|
throw this.invalid();
|
|
const next = this.tokens.newRefresh();
|
|
const nextHash = refreshHmac(next);
|
|
const usedAt = new Date();
|
|
const changed = await tx.refreshToken.updateMany({
|
|
where: { id: stored.id, status: "ACTIVE" },
|
|
data: { status: "USED", usedAt, replacedByHash: nextHash },
|
|
});
|
|
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
|
|
await tx.refreshToken.create({
|
|
data: {
|
|
tokenHash: nextHash,
|
|
sessionId: session.id,
|
|
generation: stored.generation + 1,
|
|
expiresAt: session.expiresAt,
|
|
},
|
|
});
|
|
await tx.session.update({
|
|
where: { id: session.id },
|
|
data: {
|
|
previousTokenHash: hash,
|
|
refreshTokenHash: nextHash,
|
|
generation: stored.generation + 1,
|
|
rotatedAt: usedAt,
|
|
},
|
|
});
|
|
return this.pair(
|
|
session.account,
|
|
session.id,
|
|
session.deviceId ?? "",
|
|
next,
|
|
);
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
|
);
|
|
if ("reusedFamily" in result)
|
|
return this.revokeReusedFamily(result.reusedFamily);
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof DomainException) throw error;
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
(error.code === "P2034" || error.code === "P2002")
|
|
) {
|
|
if (retryAttempt < 3) {
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, 10 * 2 ** retryAttempt),
|
|
);
|
|
return this.refresh(token, ip, retryAttempt + 1);
|
|
}
|
|
const raced = await this.prisma.refreshToken.findUnique({
|
|
where: { tokenHash: hash },
|
|
include: { session: true },
|
|
});
|
|
if (raced?.status === "USED")
|
|
return this.revokeReusedFamily(raced.session.tokenFamily);
|
|
throw new DomainException(
|
|
ErrorCode.SERVICE_UNAVAILABLE,
|
|
"Authentication temporarily unavailable",
|
|
HttpStatus.SERVICE_UNAVAILABLE,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async cleanupExpiredRefreshTokens(sessionId?: string): Promise<number> {
|
|
const deleted = await this.prisma.refreshToken.deleteMany({
|
|
where: {
|
|
expiresAt: { lt: new Date() },
|
|
...(sessionId ? { sessionId } : {}),
|
|
},
|
|
});
|
|
return deleted.count;
|
|
}
|
|
private async revokeReusedFamily(family: string): Promise<never> {
|
|
await this.prisma.session.updateMany({
|
|
where: { tokenFamily: family },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
throw new DomainException(
|
|
ErrorCode.AUTH_REFRESH_REUSED,
|
|
"Refresh token reused",
|
|
HttpStatus.UNAUTHORIZED,
|
|
);
|
|
}
|
|
async logout(token: string | undefined): Promise<void> {
|
|
if (!token || !this.validRefreshShape(token)) return;
|
|
const stored = await this.prisma.refreshToken.findUnique({
|
|
where: { tokenHash: refreshHmac(token) },
|
|
include: { session: true },
|
|
});
|
|
if (!stored) return;
|
|
await this.prisma.session.updateMany({
|
|
where: { tokenFamily: stored.session.tokenFamily },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|
|
private validRefreshShape(token: string): boolean {
|
|
return /^[A-Za-z0-9_-]{43}$/.test(token);
|
|
}
|
|
private invalid() {
|
|
return new DomainException(
|
|
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
|
"Invalid credentials",
|
|
HttpStatus.UNAUTHORIZED,
|
|
);
|
|
}
|
|
}
|