feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -33,12 +33,14 @@ type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
|
||||
private readonly accessTtlSeconds = 900;
|
||||
private readonly refreshTtlDays = 30;
|
||||
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(AuthRateLimiter)
|
||||
private readonly rateLimiter: AuthRateLimiter,
|
||||
) {}
|
||||
|
||||
@@ -56,12 +58,12 @@ export class AuthService {
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
|
||||
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
|
||||
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
try {
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
// A user must never exist without its initial tenant and owner membership.
|
||||
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
|
||||
const user = await insertUser(
|
||||
transaction,
|
||||
email,
|
||||
@@ -100,7 +102,7 @@ export class AuthService {
|
||||
|
||||
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Use the same normalized value for the limiter and citext account lookup.
|
||||
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||
const result = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
@@ -133,7 +135,7 @@ export class AuthService {
|
||||
refreshToken: string | undefined,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
|
||||
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
|
||||
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||
if (!refreshToken)
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
@@ -149,7 +151,7 @@ export class AuthService {
|
||||
const user = sessions.rows[0];
|
||||
if (!user || user.status !== 'active')
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
// Row locking makes a refresh token single-use even when two browser requests arrive together.
|
||||
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
|
||||
await transaction.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||
[user.session_id],
|
||||
@@ -180,6 +182,7 @@ export class AuthService {
|
||||
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||
: null;
|
||||
if (!claims) throw new UnauthorizedException();
|
||||
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
|
||||
const active = await this.database.query(
|
||||
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||
[claims.sid, claims.sub],
|
||||
@@ -188,8 +191,8 @@ export class AuthService {
|
||||
return { userId: claims.sub, sessionId: claims.sid };
|
||||
}
|
||||
|
||||
public async me(userId: string) {
|
||||
const workspace = await this.findWorkspace(userId);
|
||||
public async me(userId: string, workspace: Workspace) {
|
||||
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||
const users = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
[userId],
|
||||
@@ -233,6 +236,7 @@ export class AuthService {
|
||||
workspace: WorkspaceRow,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
|
||||
const refreshToken = createOpaqueToken();
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||
@@ -284,6 +288,7 @@ function isUniqueViolation(error: unknown): error is { code: string } {
|
||||
);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -298,6 +303,7 @@ async function insertUser(
|
||||
passwordHash: string,
|
||||
displayName: string | null,
|
||||
) {
|
||||
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||
const result = await transaction.query<UserRow>(
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, email_verified_at, created_at, status, password_hash',
|
||||
[email, passwordHash, displayName],
|
||||
|
||||
Reference in New Issue
Block a user