feat: 实现演示验证码和令牌轮换
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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,
|
||||
encryptPhone,
|
||||
envInt,
|
||||
phoneHmac,
|
||||
refreshHmac,
|
||||
safeEqual,
|
||||
} 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 limit(
|
||||
kind: string,
|
||||
parts: string[],
|
||||
max: number,
|
||||
ttl: number,
|
||||
): Promise<void> {
|
||||
await this.redis.ensureConnected();
|
||||
const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`;
|
||||
const result = (await this.redis.client.eval(
|
||||
`local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`,
|
||||
{ keys: [key], arguments: [String(ttl)] },
|
||||
)) as number;
|
||||
if (result > max)
|
||||
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);
|
||||
await Promise.all([
|
||||
this.limit(
|
||||
"send-phone",
|
||||
[digest],
|
||||
envInt("SMS_SEND_LIMIT", 3),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"send-device",
|
||||
[deviceId],
|
||||
envInt("SMS_SEND_DEVICE_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"send-ip",
|
||||
[phoneHmac(ip)],
|
||||
envInt("SMS_SEND_IP_LIMIT", 10),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
]);
|
||||
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 (process.env.NODE_ENV !== "production") 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);
|
||||
await Promise.all([
|
||||
this.limit(
|
||||
"verify-phone",
|
||||
[digest],
|
||||
envInt("SMS_VERIFY_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"verify-combined",
|
||||
[digest, phoneHmac(ip), deviceId],
|
||||
envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
]);
|
||||
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();
|
||||
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(id);
|
||||
const expiresAt = new Date(
|
||||
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
|
||||
);
|
||||
await this.prisma.session.create({
|
||||
data: {
|
||||
id,
|
||||
accountId: account.id,
|
||||
deviceId,
|
||||
refreshTokenHash: refreshHmac(refresh),
|
||||
tokenFamily: id,
|
||||
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): Promise<TokenPair> {
|
||||
const parsed = this.parseRefresh(token);
|
||||
const hash = refreshHmac(token);
|
||||
const observed = await this.prisma.session.findUnique({
|
||||
where: { id: parsed },
|
||||
});
|
||||
if (
|
||||
observed?.previousTokenHash &&
|
||||
safeEqual(observed.previousTokenHash, hash)
|
||||
)
|
||||
return this.revokeReusedFamily(observed.tokenFamily);
|
||||
|
||||
try {
|
||||
const result = await this.prisma.$transaction(
|
||||
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
|
||||
const session = await tx.session.findUnique({
|
||||
where: { id: parsed },
|
||||
include: { account: true },
|
||||
});
|
||||
if (!session) throw this.invalid();
|
||||
if (
|
||||
session.previousTokenHash &&
|
||||
safeEqual(session.previousTokenHash, hash)
|
||||
)
|
||||
return { reusedFamily: session.tokenFamily };
|
||||
if (
|
||||
!safeEqual(session.refreshTokenHash, hash) ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= new Date() ||
|
||||
session.account.status !== "ACTIVE"
|
||||
)
|
||||
throw this.invalid();
|
||||
const next = this.tokens.newRefresh(session.id);
|
||||
const changed = await tx.session.updateMany({
|
||||
where: { id: session.id, refreshTokenHash: hash, revokedAt: null },
|
||||
data: {
|
||||
previousTokenHash: hash,
|
||||
refreshTokenHash: refreshHmac(next),
|
||||
generation: { increment: 1 },
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
|
||||
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"
|
||||
) {
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: parsed },
|
||||
});
|
||||
if (
|
||||
session?.previousTokenHash &&
|
||||
safeEqual(session.previousTokenHash, hash)
|
||||
)
|
||||
return this.revokeReusedFamily(session.tokenFamily);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
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) return;
|
||||
let id: string;
|
||||
try {
|
||||
id = this.parseRefresh(token);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const hash = refreshHmac(token);
|
||||
await this.prisma.session.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }],
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
private parseRefresh(token: string): string {
|
||||
const id = token.split(".")[0];
|
||||
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid();
|
||||
return id;
|
||||
}
|
||||
private invalid() {
|
||||
return new DomainException(
|
||||
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||
"Invalid credentials",
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user