feat(auth): add SMS OTP verification for phone registration and login

- auth-service: add SmsService (Aliyun SMS) + RedisProvider for OTP storage
- POST /api/v1/auth/sms/send — send OTP (rate limited 1/min per phone)
- POST /api/v1/auth/sms/verify — verify OTP only
- POST /api/v1/auth/login/otp — passwordless login with phone + OTP
- register endpoint now requires smsCode when registering with phone
- Web Admin register page: add OTP input + 60s countdown button for phone mode
- Flutter login page: add 验证码登录 tab with phone + OTP flow
- SMS enabled via ALIYUN_ACCESS_KEY_ID/SECRET + SMS_ENABLED=true env vars
- Falls back to mock mode (logs code) when env vars not set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 06:43:27 -08:00
parent 2773b6265c
commit 71ea80972d
13 changed files with 877 additions and 90 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useTranslation } from 'react-i18next';
@ -23,6 +23,10 @@ export default function RegisterPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0);
const [smsSending, setSmsSending] = useState(false);
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [companyName, setCompanyName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@ -40,6 +44,27 @@ export default function RegisterPage() {
.catch(() => {});
}, []);
const handleSendSms = async () => {
if (!phone.trim()) { setError('请先填写手机号'); return; }
setError(null);
setSmsSending(true);
try {
await apiClient('/api/v1/auth/sms/send', { method: 'POST', body: { phone: phone.trim(), purpose: 'register' } });
setSmsCooldown(60);
if (cooldownRef.current) clearInterval(cooldownRef.current);
cooldownRef.current = setInterval(() => {
setSmsCooldown((prev) => {
if (prev <= 1) { clearInterval(cooldownRef.current!); return 0; }
return prev - 1;
});
}, 1000);
} catch (err) {
setError(err instanceof Error ? err.message : '发送失败,请重试');
} finally {
setSmsSending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
@ -67,7 +92,9 @@ export default function RegisterPage() {
if (loginMethod === 'email') {
body.email = email;
} else {
if (!smsCode.trim()) { setError('请输入短信验证码'); setIsLoading(false); return; }
body.phone = phone;
body.smsCode = smsCode;
}
const data = await apiClient<RegisterResponse>('/api/v1/auth/register', {
@ -149,17 +176,41 @@ export default function RegisterPage() {
/>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">{t('phone')}</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('phonePlaceholder')}
required
/>
</div>
<>
<div>
<label className="block text-sm font-medium mb-1">{t('phone')}</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('phonePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="flex gap-2">
<input
type="text"
value={smsCode}
onChange={(e) => setSmsCode(e.target.value)}
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
placeholder="请输入 6 位验证码"
maxLength={6}
required
/>
<button
type="button"
onClick={handleSendSms}
disabled={smsSending || smsCooldown > 0}
className="px-4 py-2 text-sm font-medium bg-secondary text-secondary-foreground rounded-md hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
>
{smsSending ? '发送中...' : smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
</div>
</>
)}
{/* Full name */}

View File

@ -10,9 +10,12 @@ class ApiEndpoints {
// Auth
static const String login = '$auth/login';
static const String loginOtp = '$auth/login/otp';
static const String register = '$auth/register';
static const String profile = '$auth/profile';
static const String refreshToken = '$auth/refresh';
static const String smsSend = '$auth/sms/send';
static const String smsVerify = '$auth/sms/verify';
// Agent
static const String tasks = '$agent/tasks';

View File

@ -135,6 +135,44 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
/// Send SMS OTP to the given phone number.
Future<void> sendSmsCode(String phone) async {
final config = _ref.read(appConfigProvider);
final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl));
await dio.post(ApiEndpoints.smsSend, data: {'phone': phone, 'purpose': 'login'});
}
/// Login with phone number + OTP (passwordless).
Future<bool> loginWithOtp(String phone, String smsCode) async {
state = state.copyWith(isLoading: true, error: null);
try {
final config = _ref.read(appConfigProvider);
final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl));
final response = await dio.post(
ApiEndpoints.loginOtp,
data: {'phone': phone, 'smsCode': smsCode},
);
final authResponse = AuthResponse.fromJson(response.data as Map<String, dynamic>);
final storage = _ref.read(secureStorageProvider);
await storage.write(key: _keyAccessToken, value: authResponse.accessToken);
await storage.write(key: _keyRefreshToken, value: authResponse.refreshToken);
state = state.copyWith(isAuthenticated: true, isLoading: false, user: authResponse.user);
_ref.invalidate(accessTokenProvider);
if (authResponse.user.tenantId != null) {
_ref.read(currentTenantIdProvider.notifier).state = authResponse.user.tenantId;
}
_connectNotifications(authResponse.user.tenantId);
return true;
} on DioException catch (e) {
final message = (e.response?.data is Map) ? e.response?.data['message'] : null;
state = state.copyWith(isLoading: false, error: message?.toString() ?? '验证码登录失败');
return false;
} catch (e) {
state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e));
return false;
}
}
Future<bool> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
@ -5,6 +6,8 @@ import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/providers/auth_provider.dart';
enum _LoginMode { password, otp }
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@ -16,6 +19,56 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _phoneController = TextEditingController();
final _otpController = TextEditingController();
_LoginMode _mode = _LoginMode.password;
int _smsCooldown = 0;
bool _smsSending = false;
Timer? _cooldownTimer;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_phoneController.dispose();
_otpController.dispose();
_cooldownTimer?.cancel();
super.dispose();
}
Future<void> _sendSmsCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先输入手机号')),
);
return;
}
setState(() => _smsSending = true);
try {
await ref.read(authStateProvider.notifier).sendSmsCode(phone);
setState(() => _smsCooldown = 60);
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (t) {
setState(() {
if (_smsCooldown <= 1) {
_smsCooldown = 0;
t.cancel();
} else {
_smsCooldown--;
}
});
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
} finally {
if (mounted) setState(() => _smsSending = false);
}
}
@override
Widget build(BuildContext context) {
@ -33,11 +86,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/logo.svg',
width: 96,
height: 96,
),
SvgPicture.asset('assets/icons/logo.svg', width: 96, height: 96),
const SizedBox(height: 12),
const Text(
'iAgent',
@ -51,69 +100,117 @@ class _LoginPageState extends ConsumerState<LoginPage> {
const SizedBox(height: 4),
const Text(
'服务器集群运维智能体',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
style: TextStyle(color: AppColors.textSecondary, fontSize: 14),
),
const SizedBox(height: 32),
// Login mode toggle
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceLight),
),
child: Row(
children: [
Expanded(child: _modeButton('密码登录', _LoginMode.password)),
Expanded(child: _modeButton('验证码登录', _LoginMode.otp)),
],
),
),
const SizedBox(height: 48),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: 'user@example.com',
prefixIcon: Icon(Icons.email_outlined),
const SizedBox(height: 24),
if (_mode == _LoginMode.password) ...[
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: 'user@example.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (v) {
if (v == null || v.isEmpty) return '请输入邮箱地址';
if (!v.contains('@')) return '请输入有效的邮箱地址';
return null;
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入邮箱地址';
}
if (!value.contains('@')) {
return '请输入有效的邮箱地址';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (v) => (v == null || v.isEmpty) ? '请输入密码' : null,
onFieldSubmitted: (_) => _handlePasswordLogin(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
] else ...[
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: '手机号',
hintText: '+86 138 0000 0000',
prefixIcon: Icon(Icons.phone_outlined),
),
validator: (v) => (v == null || v.isEmpty) ? '请输入手机号' : null,
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _otpController,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: const InputDecoration(
labelText: '验证码',
hintText: '6 位数字',
prefixIcon: Icon(Icons.sms_outlined),
counterText: '',
),
validator: (v) => (v == null || v.isEmpty) ? '请输入验证码' : null,
onFieldSubmitted: (_) => _handleOtpLogin(),
),
),
const SizedBox(width: 8),
SizedBox(
height: 56,
child: OutlinedButton(
onPressed: (_smsSending || _smsCooldown > 0) ? null : _sendSmsCode,
child: Text(
_smsSending
? '发送中'
: _smsCooldown > 0
? '${_smsCooldown}s'
: '获取验证码',
style: const TextStyle(fontSize: 13),
),
),
),
],
),
],
if (authState.error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.error.withAlpha(25),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline,
color: AppColors.error, size: 18),
const Icon(Icons.error_outline, color: AppColors.error, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
authState.error!,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
),
style: const TextStyle(color: AppColors.error, fontSize: 13),
),
),
],
@ -125,29 +222,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: authState.isLoading ? null : _handleLogin,
onPressed: authState.isLoading
? null
: (_mode == _LoginMode.password
? _handlePasswordLogin
: _handleOtpLogin),
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
strokeWidth: 2, color: Colors.white),
)
: const Text(
'登录',
style: TextStyle(fontSize: 16),
),
: const Text('登录', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 32),
Text(
'账号由管理员在后台创建或通过邀请链接注册',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
textAlign: TextAlign.center,
),
],
@ -159,23 +252,44 @@ class _LoginPageState extends ConsumerState<LoginPage> {
);
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
Widget _modeButton(String label, _LoginMode mode) {
final selected = _mode == mode;
return GestureDetector(
onTap: () => setState(() => _mode = mode),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: selected ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
Future<void> _handlePasswordLogin() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authStateProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
context.go('/dashboard');
}
if (success && mounted) context.go('/dashboard');
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
Future<void> _handleOtpLogin() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authStateProvider.notifier).loginWithOtp(
_phoneController.text.trim(),
_otpController.text.trim(),
);
if (success && mounted) context.go('/dashboard');
}
}

View File

@ -27,7 +27,11 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"@it0/common": "workspace:*",
"@it0/database": "workspace:*"
"@it0/database": "workspace:*",
"@alicloud/dysmsapi20170525": "^4.3.1",
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/tea-util": "^1.4.11",
"ioredis": "^5.3.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",

View File

@ -105,6 +105,30 @@ export class AuthService {
};
}
/**
* Login by phone number only (OTP already verified by controller).
* The user must exist in public.users with a matching phone.
*/
async loginByPhone(phone: string): Promise<{
accessToken: string;
refreshToken: string;
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: string };
}> {
const rows = await this.dataSource.query(
`SELECT * FROM public.users WHERE phone = $1 AND is_active = true LIMIT 1`, [phone],
);
if (!rows.length) throw new UnauthorizedException('该手机号未注册');
const user = this.mapRow(rows[0]);
await this.dataSource.query(
`UPDATE public.users SET last_login_at = NOW() WHERE id = $1`, [user.id],
);
const tokens = this.generateTokens(user);
return {
...tokens,
user: { id: user.id, email: user.email, phone: user.phone, name: user.name, roles: user.roles, tenantId: user.tenantId },
};
}
/**
* Register a new user.
* If companyName is provided, creates a new tenant with schema provisioning

View File

@ -10,11 +10,14 @@ import { UserController } from './interfaces/rest/controllers/user.controller';
import { SettingsController } from './interfaces/rest/controllers/settings.controller';
import { RoleController } from './interfaces/rest/controllers/role.controller';
import { PermissionController } from './interfaces/rest/controllers/permission.controller';
import { SmsController } from './interfaces/rest/controllers/sms.controller';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
import { RbacGuard } from './infrastructure/guards/rbac.guard';
import { AuthService } from './application/services/auth.service';
import { UserRepository } from './infrastructure/repositories/user.repository';
import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository';
import { SmsService } from './infrastructure/sms/sms.service';
import { RedisProvider } from './infrastructure/redis/redis.provider';
import { User } from './domain/entities/user.entity';
import { Role } from './domain/entities/role.entity';
import { ApiKey } from './domain/entities/api-key.entity';
@ -34,7 +37,7 @@ import { TenantInvite } from './domain/entities/tenant-invite.entity';
}),
}),
],
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController],
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController, SmsController],
providers: [
JwtStrategy,
RbacGuard,
@ -42,6 +45,8 @@ import { TenantInvite } from './domain/entities/tenant-invite.entity';
UserRepository,
ApiKeyRepository,
TenantProvisioningService,
SmsService,
RedisProvider,
],
exports: [AuthService],
})

View File

@ -0,0 +1,18 @@
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
export const REDIS_CLIENT = 'REDIS_CLIENT';
export const RedisProvider: Provider = {
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return new Redis({
host: config.get('REDIS_HOST', 'redis'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD') || undefined,
lazyConnect: true,
});
},
};

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}

View File

@ -0,0 +1,100 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import * as $OpenApi from '@alicloud/openapi-client';
import * as $Util from '@alicloud/tea-util';
export interface SmsSendResult {
success: boolean;
requestId?: string;
bizId?: string;
code?: string;
message?: string;
}
@Injectable()
export class SmsService implements OnModuleInit {
private readonly logger = new Logger(SmsService.name);
private client: Dysmsapi20170525 | null = null;
private readonly signName: string;
private readonly templateCode: string;
private readonly enabled: boolean;
constructor(private readonly configService: ConfigService) {
this.signName = this.configService.get('ALIYUN_SMS_SIGN_NAME', '');
this.templateCode = this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
this.enabled = this.configService.get('SMS_ENABLED') === 'true';
}
async onModuleInit() {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
if (!accessKeyId || !accessKeySecret) {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
return;
}
try {
const config = new $OpenApi.Config({
accessKeyId,
accessKeySecret,
endpoint: 'dysmsapi.aliyuncs.com',
});
this.client = new Dysmsapi20170525(config);
this.logger.log('阿里云 SMS 客户端初始化成功');
} catch (error) {
this.logger.error('阿里云 SMS 客户端初始化失败', error);
}
}
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
const normalized = this.normalizePhone(phoneNumber);
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhone(normalized)}`);
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code}${this.maskPhone(normalized)}`);
return { success: true, code: 'OK', message: '模拟发送成功' };
}
try {
const req = new $Dysmsapi20170525.SendSmsRequest({
phoneNumbers: normalized,
signName: this.signName,
templateCode: this.templateCode,
templateParam: JSON.stringify({ code }),
});
const runtime = new $Util.RuntimeOptions({ connectTimeout: 15000, readTimeout: 15000 });
const resp = await this.client.sendSmsWithOptions(req, runtime);
const body = resp.body;
const result: SmsSendResult = {
success: body?.code === 'OK',
requestId: body?.requestId,
bizId: body?.bizId,
code: body?.code,
message: body?.message,
};
if (result.success) {
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}`);
} else {
this.logger.error(`[SMS] 发送失败: code=${result.code}, msg=${result.message}`);
}
return result;
} catch (error: any) {
this.logger.error(`[SMS] 发送异常: ${error.message}`);
return { success: false, code: error.code || 'UNKNOWN_ERROR', message: error.message };
}
}
private normalizePhone(phone: string): string {
let p = phone.trim();
if (p.startsWith('+86')) p = p.slice(3);
else if (p.startsWith('86') && p.length === 13) p = p.slice(2);
return p;
}
private maskPhone(phone: string): string {
if (phone.length < 7) return phone;
return phone.slice(0, 3) + '****' + phone.slice(-4);
}
}

View File

@ -1,10 +1,15 @@
import { Controller, Post, Body, Get, Param, UseGuards, Request } from '@nestjs/common';
import { Controller, Post, Body, Get, Param, UseGuards, Request, Inject, BadRequestException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from '../../../application/services/auth.service';
import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider';
import Redis from 'ioredis';
@Controller('api/v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
@Post('login')
async login(
@ -21,6 +26,7 @@ export class AuthController {
* Register a new enterprise account.
* Supports email or phone as the login identifier.
* If companyName is provided, creates a new tenant (self-service registration).
* Phone registration requires a valid OTP (smsCode).
*/
@Post('register')
async register(
@ -28,11 +34,25 @@ export class AuthController {
body: {
email?: string;
phone?: string;
smsCode?: string;
password: string;
name: string;
companyName?: string;
},
) {
// Verify OTP when registering with phone
if (body.phone) {
const phone = body.phone.trim();
if (!body.smsCode) throw new BadRequestException('手机注册需要验证码');
const otpKey = `sms:otp:${phone}`;
const stored = await this.redis.get(otpKey);
if (!stored || stored !== body.smsCode.trim()) {
throw new BadRequestException('验证码错误或已过期');
}
// Consume the OTP immediately
await this.redis.del(otpKey);
}
return this.authService.register(
body.password,
body.name,
@ -42,6 +62,26 @@ export class AuthController {
);
}
/**
* Login with phone number + OTP (passwordless).
* POST /api/v1/auth/login/otp
*/
@Post('login/otp')
async loginWithOtp(@Body() body: { phone: string; smsCode: string }) {
const phone = (body.phone || '').trim();
const code = (body.smsCode || '').trim();
if (!phone || !code) throw new BadRequestException('手机号和验证码不能为空');
const otpKey = `sms:otp:${phone}`;
const stored = await this.redis.get(otpKey);
if (!stored || stored !== code) {
throw new BadRequestException('验证码错误或已过期');
}
await this.redis.del(otpKey);
return this.authService.loginByPhone(phone);
}
/**
* Validate an invitation token (public endpoint).
*/

View File

@ -0,0 +1,83 @@
import {
Controller,
Post,
Body,
Inject,
BadRequestException,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { SmsService } from '../../../infrastructure/sms/sms.service';
import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider';
import Redis from 'ioredis';
const OTP_TTL = 300; // 5 minutes
const RATE_LIMIT_TTL = 60; // 1 per minute per phone
function generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
@Controller('api/v1/auth/sms')
export class SmsController {
constructor(
private readonly smsService: SmsService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
/**
* POST /api/v1/auth/sms/send
* Send OTP to phone. Rate-limited: 1 per minute per phone number.
* purpose: 'register' | 'login'
*/
@Post('send')
@HttpCode(HttpStatus.OK)
async sendOtp(@Body() body: { phone: string; purpose?: string }) {
const phone = (body.phone || '').trim();
if (!phone) throw new BadRequestException('手机号不能为空');
const rateKey = `sms:rate:${phone}`;
const already = await this.redis.get(rateKey);
if (already) {
throw new BadRequestException('发送太频繁,请 60 秒后再试');
}
const code = generateCode();
const otpKey = `sms:otp:${phone}`;
// Store OTP first, then send (avoid race condition)
await this.redis.set(otpKey, code, 'EX', OTP_TTL);
await this.redis.set(rateKey, '1', 'EX', RATE_LIMIT_TTL);
const result = await this.smsService.sendVerificationCode(phone, code);
if (!result.success) {
// Clean up if SMS actually failed
await this.redis.del(otpKey);
await this.redis.del(rateKey);
throw new BadRequestException(result.message || '短信发送失败,请稍后重试');
}
return { message: '验证码已发送', expiresIn: OTP_TTL };
}
/**
* POST /api/v1/auth/sms/verify
* Verify OTP only (does not log in). Returns 200 on success.
*/
@Post('verify')
@HttpCode(HttpStatus.OK)
async verifyOtp(@Body() body: { phone: string; code: string }) {
const phone = (body.phone || '').trim();
const code = (body.code || '').trim();
if (!phone || !code) throw new BadRequestException('手机号和验证码不能为空');
const otpKey = `sms:otp:${phone}`;
const stored = await this.redis.get(otpKey);
if (!stored || stored !== code) {
throw new BadRequestException('验证码错误或已过期');
}
return { verified: true };
}
}

View File

@ -190,6 +190,15 @@ importers:
packages/services/auth-service:
dependencies:
'@alicloud/dysmsapi20170525':
specifier: ^4.3.1
version: 4.5.0
'@alicloud/openapi-client':
specifier: ^0.4.15
version: 0.4.15
'@alicloud/tea-util':
specifier: ^1.4.11
version: 1.4.11
'@it0/common':
specifier: workspace:*
version: link:../../shared/common
@ -223,6 +232,9 @@ importers:
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
ioredis:
specifier: ^5.3.0
version: 5.9.2
passport:
specifier: ^0.7.0
version: 0.7.0
@ -834,6 +846,60 @@ importers:
packages:
'@alicloud/credentials@2.4.4':
resolution: {integrity: sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==}
'@alicloud/darabonba-array@0.1.2':
resolution: {integrity: sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==}
'@alicloud/darabonba-encode-util@0.0.1':
resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==}
'@alicloud/darabonba-encode-util@0.0.2':
resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==}
'@alicloud/darabonba-map@0.0.1':
resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==}
'@alicloud/darabonba-signature-util@0.0.4':
resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==}
'@alicloud/darabonba-string@1.0.3':
resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==}
'@alicloud/dysmsapi20170525@4.5.0':
resolution: {integrity: sha512-nhKdRDLRDhTVxr7VbMbBi6UtJWmVFgwySU2ohkJ1zL7jd98DEGGy8CE/n7W44ZP9+yTBBmLhM8qW1C12kHDEIg==}
'@alicloud/endpoint-util@0.0.1':
resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==}
'@alicloud/gateway-pop@0.0.6':
resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==}
'@alicloud/gateway-spi@0.0.8':
resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==}
'@alicloud/openapi-client@0.4.15':
resolution: {integrity: sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==}
'@alicloud/openapi-core@1.0.7':
resolution: {integrity: sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==}
'@alicloud/openapi-util@0.3.3':
resolution: {integrity: sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==}
'@alicloud/tea-typescript@1.8.0':
resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==}
'@alicloud/tea-util@1.4.11':
resolution: {integrity: sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==}
'@alicloud/tea-util@1.4.9':
resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==}
'@alicloud/tea-xml@0.0.3':
resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==}
'@angular-devkit/core@17.3.11':
resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@ -1033,6 +1099,9 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
'@darabonba/typescript@1.0.4':
resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==}
'@fidm/asn1@1.0.4':
resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==}
engines: {node: '>= 8'}
@ -1558,12 +1627,18 @@ packages:
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
'@types/nodemailer@6.4.22':
resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==}
@ -1607,6 +1682,9 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/xml2js@0.4.14':
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@ -2611,6 +2689,9 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpx@2.3.3:
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@ -2645,6 +2726,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inquirer@8.2.6:
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
engines: {node: '>=12.0.0'}
@ -2970,6 +3054,9 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
kitx@2.2.0:
resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@ -3140,6 +3227,12 @@ packages:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@ -3584,6 +3677,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.5.0:
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
engines: {node: '>=11.0.0'}
schema-utils@3.3.0:
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
engines: {node: '>= 10.13.0'}
@ -3669,6 +3766,9 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
sm3@1.0.3:
resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@ -4249,6 +4349,14 @@ packages:
utf-8-validate:
optional: true
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
xmlbuilder@13.0.2:
resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==}
engines: {node: '>=6.0'}
@ -4292,6 +4400,146 @@ packages:
snapshots:
'@alicloud/credentials@2.4.4':
dependencies:
'@alicloud/tea-typescript': 1.8.0
httpx: 2.3.3
ini: 1.3.8
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-array@0.1.2':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-encode-util@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
moment: 2.30.1
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-encode-util@0.0.2':
dependencies:
moment: 2.30.1
'@alicloud/darabonba-map@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-signature-util@0.0.4':
dependencies:
'@alicloud/darabonba-encode-util': 0.0.1
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-string@1.0.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/dysmsapi20170525@4.5.0':
dependencies:
'@alicloud/openapi-core': 1.0.7
'@darabonba/typescript': 1.0.4
transitivePeerDependencies:
- supports-color
'@alicloud/endpoint-util@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/gateway-pop@0.0.6':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/darabonba-array': 0.1.2
'@alicloud/darabonba-encode-util': 0.0.2
'@alicloud/darabonba-map': 0.0.1
'@alicloud/darabonba-signature-util': 0.0.4
'@alicloud/darabonba-string': 1.0.3
'@alicloud/endpoint-util': 0.0.1
'@alicloud/gateway-spi': 0.0.8
'@alicloud/openapi-util': 0.3.3
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.11
transitivePeerDependencies:
- supports-color
'@alicloud/gateway-spi@0.0.8':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-client@0.4.15':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/gateway-spi': 0.0.8
'@alicloud/openapi-util': 0.3.3
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.9
'@alicloud/tea-xml': 0.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-core@1.0.7':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/gateway-pop': 0.0.6
'@alicloud/gateway-spi': 0.0.8
'@darabonba/typescript': 1.0.4
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-util@0.3.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.11
kitx: 2.2.0
sm3: 1.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/tea-typescript@1.8.0':
dependencies:
'@types/node': 12.20.55
httpx: 2.3.3
transitivePeerDependencies:
- supports-color
'@alicloud/tea-util@1.4.11':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@darabonba/typescript': 1.0.4
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/tea-util@1.4.9':
dependencies:
'@alicloud/tea-typescript': 1.8.0
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/tea-xml@0.0.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@types/xml2js': 0.4.14
xml2js: 0.6.2
transitivePeerDependencies:
- supports-color
'@angular-devkit/core@17.3.11(chokidar@3.6.0)':
dependencies:
ajv: 8.12.0
@ -4544,6 +4792,17 @@ snapshots:
'@colors/colors@1.5.0':
optional: true
'@darabonba/typescript@1.0.4':
dependencies:
'@alicloud/tea-typescript': 1.8.0
httpx: 2.3.3
lodash: 4.17.23
moment: 2.30.1
moment-timezone: 0.5.48
xml2js: 0.6.2
transitivePeerDependencies:
- supports-color
'@fidm/asn1@1.0.4': {}
'@fidm/x509@1.2.1':
@ -5178,6 +5437,8 @@ snapshots:
'@types/node': 20.19.33
form-data: 4.0.5
'@types/node@12.20.55': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
@ -5186,6 +5447,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
'@types/nodemailer@6.4.22':
dependencies:
'@types/node': 20.19.33
@ -5245,6 +5510,10 @@ snapshots:
dependencies:
'@types/node': 20.19.33
'@types/xml2js@0.4.14':
dependencies:
'@types/node': 20.19.33
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.35':
@ -6365,6 +6634,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpx@2.3.3:
dependencies:
'@types/node': 20.19.33
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {}
humanize-ms@1.2.1:
@ -6396,6 +6672,8 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.8: {}
inquirer@8.2.6:
dependencies:
ansi-escapes: 4.3.2
@ -6954,6 +7232,10 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
kitx@2.2.0:
dependencies:
'@types/node': 22.19.15
kleur@3.0.3: {}
leven@3.1.0: {}
@ -7078,6 +7360,12 @@ snapshots:
dependencies:
minimist: 1.2.8
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
ms@2.0.0: {}
ms@2.1.3: {}
@ -7497,6 +7785,8 @@ snapshots:
safer-buffer@2.1.2: {}
sax@1.5.0: {}
schema-utils@3.3.0:
dependencies:
'@types/json-schema': 7.0.15
@ -7608,6 +7898,8 @@ snapshots:
slash@3.0.0: {}
sm3@1.0.3: {}
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
@ -8140,6 +8432,13 @@ snapshots:
ws@8.18.3: {}
xml2js@0.6.2:
dependencies:
sax: 1.5.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
xmlbuilder@13.0.2: {}
xmlbuilder@15.1.1: {}