This commit is contained in:
2025-08-18 19:32:43 +05:30
parent 6689f1d8d2
commit f424d610bb
6 changed files with 224 additions and 160 deletions

143
OTP.md Normal file
View File

@@ -0,0 +1,143 @@
# OTP API Documentation
## Overview
The OTP system handles two main flows:
1. **Login OTP**: Passwordless login using phone number
2. **Verification OTP**: Phone number verification during registration
Base URL: `/server/auth`
## Login OTP Flow
### 1. Generate Login OTP
**Endpoint:** `POST /auth/send-otp`
**Request Body:**
```json
{
"phone": "1234567890"
}
```
**Success Response (200):**
```json
{
"message": "OTP sent successfully"
}
```
**Error Responses:**
- `400 Bad Request`: Phone number is required
- `400 Bad Request`: Admin users should use regular login, not OTP
- `400 Bad Request`: Please verify your phone number first (for unverified users)
### 2. Verify Login OTP
**Endpoint:** `POST /auth/verify-otp`
**Request Body:**
```json
{
"phone": "1234567890",
"otp": "123456"
}
```
**Success Response (200):**
```json
{
"message": "OTP verified successfully"
}
```
**Notes:**
- Sets JWT token in HTTP-only cookie
- Returns different user roles (USER, VIEWER, etc.)
- If user exists: logs in with actual user data
- If user doesn't exist: creates VIEWER session
**Error Responses:**
- `400 Bad Request`: Phone number and OTP are required
- `400 Bad Request`: Invalid or expired OTP
## Phone Verification Flow
### 1. Send Verification OTP
**Endpoint:** `POST /auth/send-verification-otp`
**Request Body:**
```json
{
"phone": "1234567890"
}
```
**Success Response (200):**
```json
{
"message": "Verification OTP sent to your phone"
}
```
**Error Responses:**
- `400 Bad Request`: Phone number is required
- `400 Bad Request`: User not found
- `400 Bad Request`: Phone number is already verified
### 2. Verify Account
**Endpoint:** `POST /auth/verify-account`
**Request Body:**
```json
{
"phone": "1234567890",
"otp": "123456"
}
```
**Success Response (200):**
```json
{
"message": "Phone number verified successfully",
"user": {
"id": "user-uuid",
"name": "User Name",
"phone_number": "1234567890",
"role": "USER",
"phone_verified": true
}
}
```
**Error Responses:**
- `400 Bad Request`: Phone number and OTP are required
- `400 Bad Request`: Invalid or expired verification OTP
## Important Notes
### OTP Specifications
- **Length**: 6 digits
- **Login OTP Expiry**: 5 minutes
- **Verification OTP Expiry**: 10 minutes
- **Format**: Numeric only (100000-999999)
### Security Features
- Only one active OTP per phone/purpose at a time
- Previous OTPs are automatically deactivated when new ones are generated
- OTPs are single-use (marked as used after verification)
- Expired OTPs are automatically rejected
### User Roles & Permissions
- **SUPER_ADMIN, EDITOR, REVIEWER, VIEWER**: Cannot use login OTP (must use regular login)
- **USER**: Can use login OTP only after phone verification
- **Unregistered numbers**: Get VIEWER role after OTP login
### SMS Integration
- Uses SMS service for actual delivery
- Falls back to console logging if SMS fails
- Returns success even if SMS delivery fails (for development)
### Cookie Management
- JWT tokens stored in HTTP-only cookies
- Cookie settings: `httpOnly: true, sameSite: 'strict', secure: true`
- Automatic cookie setting on successful OTP verification
- **Phone verification automatically updates JWT cookie** with new `phone_verified: true` status

View File

@@ -39,7 +39,7 @@ export class AuthController {
path: '/',
});
return { response: 'ok', user };
return { response: 'ok', user, };
}
@Post('logout')
@@ -117,49 +117,36 @@ export class AuthController {
@Public()
@Post('send-verification-otp')
async sendVerificationOtp(
@Body('emailOrPhone') emailOrPhone: string,
@Body('type') type: 'email' | 'phone',
) {
if (!emailOrPhone || !type) {
throw new BadRequestException('Email/Phone and type are required');
async sendVerificationOtp(@Body('phone') phone: string) {
if (!phone) {
throw new BadRequestException('Phone number is required');
}
if (type !== 'email' && type !== 'phone') {
throw new BadRequestException('Type must be either "email" or "phone"');
}
return this.authService.sendVerificationOtp(emailOrPhone, type);
return this.authService.sendVerificationOtp(phone);
}
@Public()
@Post('verify-account')
async verifyAccount(
@Body('emailOrPhone') emailOrPhone: string,
@Body('otp') otp: string,
@Body('type') type: 'email' | 'phone',
) {
if (!emailOrPhone || !otp || !type) {
throw new BadRequestException('Email/Phone, OTP, and type are required');
}
if (type !== 'email' && type !== 'phone') {
throw new BadRequestException('Type must be either "email" or "phone"');
}
return this.authService.verifyAccount(emailOrPhone, otp, type);
}
@Public()
@Post('auto-verify-phone')
async autoVerifyPhone(
@Body('phone') phone: string,
@Body('otp') otp: string,
@Res({ passthrough: true }) res: Response,
) {
if (!phone || !otp) {
throw new BadRequestException('Phone number and OTP are required');
}
return this.authService.autoVerifyPhone(phone, otp);
const result = await this.authService.verifyAccount(phone, otp);
if (result.token) {
res.cookie('token', result.token, {
httpOnly: true,
sameSite: 'strict',
secure: true,
path: '/',
});
}
return { message: result.message, user: result.user };
}
}

View File

@@ -28,15 +28,11 @@ export class AuthService {
const user =
await this.userService.findByPhoneOrEmailWithPassword(emailOrPhone);
if (user && (await bcrypt.compare(password, user.password))) {
// Check if user account is verified (only for USER role)
if (user.role === 'USER') {
if (!user.phone_verified) {
throw new UnauthorizedException('Please verify your phone number.');
}
// if (!user.email_verified) {
// throw new UnauthorizedException('Please verify your email address.');
// }
}
// if (user.role === 'USER') {
// if (!user.phone_verified) {
// throw new UnauthorizedException('Please verify your phone number.');
// }
// }
const { password, ...result } = user;
return result;
@@ -45,17 +41,15 @@ export class AuthService {
}
async validateViewer(phone: string) {
// For temporary viewer users, do not check DB, just accept any phone and return a minimal user-like object
return { id: null, name: null, phone_number: phone, role: 'VIEWER' };
}
async login(user: any): Promise<string> {
// If user is a temporary viewer (no id), issue a token with minimal payload
if (user.role === 'VIEWER' && !user.id) {
const payload = { sub: null, role: 'VIEWER', phone: user.phone_number };
return this.jwtService.sign(payload);
}
const payload = { name: user.name, sub: user.id, role: user.role };
const payload = { name: user.name, sub: user.id, role: user.role, phone_verified: user.phone_verified };
return this.jwtService.sign(payload);
}
@@ -64,30 +58,24 @@ export class AuthService {
}
async generateOtp(phone: string): Promise<{ message: string }> {
// Check if user exists and their role
const user = await this.userService.findByPhone(phone);
// If user is admin, reject OTP request
if (
user &&
(user.role === 'SUPER_ADMIN' ||
user.role === 'EDITOR' ||
user.role === 'REVIEWER' ||
user.role === 'VIEWER')
user.role === 'REVIEWER')
) {
throw new BadRequestException(
'Admin users should use regular login, not OTP',
);
}
// If user exists and has an account, they should verify first or use password login
if (user && user.role === 'USER') {
if (!user.phone_verified) {
throw new BadRequestException(
'Please verify your phone number first.',
);
}
}
// if (user && user.role === 'USER') {
// if (!user.phone_verified) {
// throw new BadRequestException('Please verify your phone number first.');
// }
// }
// Deactivate any existing active LOGIN OTPs for this phone number
await this.otpRepository.update(
@@ -157,6 +145,7 @@ export class AuthService {
name: otp.user.name,
phone_number: otp.user.phone_number,
role: otp.user.role,
phone_verified: otp.user.phone_verified,
};
} else {
user = { id: null, name: null, phone_number: phone, role: 'VIEWER' };
@@ -167,81 +156,59 @@ export class AuthService {
return { message: 'OTP verified successfully', token };
}
async sendVerificationOtp(
emailOrPhone: string,
type: 'email' | 'phone',
): Promise<{ message: string }> {
const user =
await this.userService.findByPhoneOrEmailWithPassword(emailOrPhone);
async sendVerificationOtp(phone: string): Promise<{ message: string }> {
const user = await this.userService.findByPhone(phone);
if (!user) {
throw new BadRequestException('User not found');
}
if (type === 'phone' && user.phone_verified) {
if (user.phone_verified) {
throw new BadRequestException('Phone number is already verified');
}
if (type === 'email' && user.email_verified) {
throw new BadRequestException('Email is already verified');
}
const purpose =
type === 'email' ? 'EMAIL_VERIFICATION' : 'PHONE_VERIFICATION';
const contact = type === 'email' ? user.email : user.phone_number;
// Deactivate any existing verification OTPs
await this.otpRepository.update(
{ phone_number: contact, purpose, isActive: true },
{ phone_number: phone, purpose: 'PHONE_VERIFICATION', isActive: true },
{ isActive: false },
);
const otpCode = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // 10 minutes for verification
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
const otp = this.otpRepository.create({
phone_number: contact,
phone_number: phone,
otp_code: otpCode,
expires_at: expiresAt,
is_verified: false,
isActive: true,
purpose,
purpose: 'PHONE_VERIFICATION',
user,
});
await this.otpRepository.save(otp);
if (type === 'phone') {
const smsSent = await this.smsService.sendOtp(contact, otpCode);
if (!smsSent) {
console.log(`SMS failed, Verification OTP for ${contact}: ${otpCode}`);
return {
message:
'Verification OTP generated but SMS delivery may have failed',
};
}
} else {
// For email, log to console (you can implement email service later)
console.log(`Email verification OTP for ${contact}: ${otpCode}`);
const smsSent = await this.smsService.sendOtp(phone, otpCode);
if (!smsSent) {
console.log(`SMS failed, Verification OTP for ${phone}: ${otpCode}`);
return {
message: 'Verification OTP generated but SMS delivery may have failed',
};
}
return { message: `Verification OTP sent to your ${type}` };
return { message: 'Verification OTP sent to your phone' };
}
async verifyAccount(
emailOrPhone: string,
phone: string,
otpCode: string,
type: 'email' | 'phone',
): Promise<{ message: string }> {
const purpose =
type === 'email' ? 'EMAIL_VERIFICATION' : 'PHONE_VERIFICATION';
): Promise<{ message: string; user?: any; token?: string }> {
const otp = await this.otpRepository.findOne({
where: {
phone_number: emailOrPhone,
phone_number: phone,
otp_code: otpCode,
purpose,
purpose: 'PHONE_VERIFICATION',
isActive: true,
is_verified: false,
expires_at: MoreThan(new Date()),
@@ -260,64 +227,24 @@ export class AuthService {
});
// Update user verification status
const updateData =
type === 'email' ? { email_verified: true } : { phone_verified: true };
await this.userService.updateUser(otp.user.id, { phone_verified: true });
await this.userService.updateUser(otp.user.id, updateData);
return {
message: `${type === 'email' ? 'Email' : 'Phone number'} verified successfully`,
// Get updated user data and generate new token
const updatedUser = await this.userService.findOneById(otp.user.id);
const userData = {
id: updatedUser.id,
name: updatedUser.name,
phone_number: updatedUser.phone_number,
role: updatedUser.role,
phone_verified: true,
};
}
async autoVerifyPhone(phone: string, otpCode: string): Promise<{ message: string; user?: any }> {
const user = await this.userService.findByPhone(phone);
if (!user) {
throw new NotFoundException('No account found with this phone number');
}
if (user.phone_verified) {
throw new BadRequestException('Phone number already verified');
}
// Find the OTP
const otp = await this.otpRepository.findOne({
where: {
phone_number: phone,
otp_code: otpCode,
purpose: 'PHONE_VERIFICATION',
isActive: true,
is_verified: false,
expires_at: MoreThan(new Date()),
},
relations: ['user'],
});
if (!otp) {
throw new BadRequestException('Invalid or expired OTP');
}
// Mark OTP as used
await this.otpRepository.update(otp.id, {
is_verified: true,
isActive: false,
});
// Update user phone verification status
await this.userService.updateUser(user.id, { phone_verified: true });
const token = await this.login(userData);
return {
message: 'Phone number verified successfully',
user: {
id: user.id,
name: user.name,
phone_number: user.phone_number,
email: user.email,
role: user.role,
phone_verified: true,
email_verified: user.email_verified,
}
user: userData,
token,
};
}
}

View File

@@ -31,7 +31,7 @@ export class CreateUserAndCustomerDto {
city: string;
@IsNotEmpty()
city_id: string;
@IsNotEmpty()
@IsOptional()
proof: string; // image id
@IsNotEmpty()
gender: GENDER;

View File

@@ -31,6 +31,7 @@ export class Customer extends BaseEntity {
@OneToOne(() => Image, (image) => image.customer, {
eager: true,
cascade: true,
nullable:true,
})
@JoinColumn()
proof: Image;

View File

@@ -56,10 +56,14 @@ export class UserService {
// Optionally create admin/customer profile if specified by role
if (user.role === Role.USER) {
await this.customerRepo.save(this.customerRepo.create({ user: newUser }));
// Send verification instructions (don't send actual OTPs during registration)
console.log(`User registered: ${newUser.email} / ${newUser.phone_number}`);
console.log('Please use /auth/send-verification-otp to verify your account');
console.log(
`User registered: ${newUser.email} / ${newUser.phone_number}`,
);
console.log(
'Please use /auth/send-verification-otp to verify your account',
);
} else {
await this.adminRepo.save(this.adminRepo.create({ user: newUser }));
}
@@ -74,9 +78,9 @@ export class UserService {
if (await this.userRepo.findOne({ where: { email: dto.email } }))
throw new ConflictException('Email already registered');
if (!(await this.imageService.exists(dto.proof))) {
throw new BadRequestException('Proof image does not exist');
}
// if (!(await this.imageService.exists(dto.proof))) {
// throw new BadRequestException('Proof image does not exist');
// }
await this.userRepo.manager.transaction(async (manager) => {
// Create user
@@ -90,9 +94,12 @@ export class UserService {
role: Role.USER,
isActive: true,
});
console.log('user', user);
const savedUser = await manager.save(User, user);
const proof = await this.imageService.confirmImage(dto.proof);
let proof;
if (dto.proof) {
proof = await this.imageService.confirmImage(dto.proof);
}
// Create customer and link image
const customer = manager.create(Customer, {
@@ -104,10 +111,9 @@ export class UserService {
city_id: dto.city_id,
gender: dto.gender,
user: savedUser,
proof,
proof: proof ?? undefined,
});
await manager.save(Customer, customer);
});
}