Add SSO handoff tickets and expand seeded countries for address forms.
Support one-time Redis-backed dashboard sign-in handoff and seed European, Middle East, and Far East countries with major cities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7277817b6e
commit
f61bba317e
@@ -13,6 +13,7 @@ import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginOtpDto } from './dto/login-otp.dto';
|
||||
import { ConsumeHandoffDto } from './dto/consume-handoff.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
@@ -60,6 +61,17 @@ export class AuthController {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('handoff')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
createHandoff(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.createHandoff(user);
|
||||
}
|
||||
|
||||
@Post('handoff/consume')
|
||||
consumeHandoff(@Body() dto: ConsumeHandoffDto) {
|
||||
return this.authService.consumeHandoff(dto.ticket);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
@@ -28,6 +30,9 @@ import { parseUserProfile } from './profile.util';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
const OTP_TTL_SECONDS = 300;
|
||||
const HANDOFF_TTL_SECONDS = 60;
|
||||
const HANDOFF_RATE_LIMIT = 10;
|
||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -216,6 +221,62 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async createHandoff(user: AuthUser) {
|
||||
if (!this.canAccessBusinessDashboard(user)) {
|
||||
throw new ForbiddenException(
|
||||
'This account cannot open the business dashboard.',
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = await this.redis.incrementWithLimit(
|
||||
`sso:handoff:rate:${user.id.toString()}`,
|
||||
HANDOFF_RATE_LIMIT,
|
||||
HANDOFF_RATE_WINDOW_SECONDS,
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new HttpException('Too many dashboard handoff requests. Try again shortly.', 429);
|
||||
}
|
||||
|
||||
const ticket = randomBytes(32).toString('hex');
|
||||
await this.redis.setHandoffTicket(
|
||||
ticket,
|
||||
user.id.toString(),
|
||||
HANDOFF_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
expiresInSeconds: HANDOFF_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async consumeHandoff(ticket: string) {
|
||||
const userId = await this.redis.consumeHandoffTicket(ticket);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Invalid or expired dashboard handoff');
|
||||
}
|
||||
|
||||
const authUser = await this.getAuthUser(BigInt(userId));
|
||||
if (!authUser || !this.canAccessBusinessDashboard(authUser)) {
|
||||
throw new ForbiddenException(
|
||||
'This account cannot access the business dashboard.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: authUser.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user: this.serializeUser(authUser),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async me(user: AuthUser) {
|
||||
return { user: this.serializeUser(user) };
|
||||
}
|
||||
@@ -526,6 +587,13 @@ export class AuthService {
|
||||
return 'customer';
|
||||
}
|
||||
|
||||
private canAccessBusinessDashboard(user: AuthUser): boolean {
|
||||
if (user.roles.includes('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
return user.businesses.length > 0;
|
||||
}
|
||||
|
||||
private async issueTokens(user: AuthUser) {
|
||||
const accessPayload: AuthJwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ConsumeHandoffDto {
|
||||
@IsString()
|
||||
@Matches(/^[a-f0-9]{64}$/, {
|
||||
message: 'ticket must be a 64-character hex string',
|
||||
})
|
||||
ticket!: string;
|
||||
}
|
||||
@@ -22,6 +22,22 @@ export class RedisService {
|
||||
await this.redis.del(`otp:${cellNumber}`);
|
||||
}
|
||||
|
||||
async setHandoffTicket(
|
||||
ticket: string,
|
||||
userId: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
await this.redis.set(`sso:handoff:${ticket}`, userId, 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
/** Atomically read and delete a one-time SSO ticket. */
|
||||
async consumeHandoffTicket(ticket: string): Promise<string | null> {
|
||||
const key = `sso:handoff:${ticket}`;
|
||||
const result = await this.redis.multi().get(key).del(key).exec();
|
||||
const value = result?.[0]?.[1];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding fixed-window counter. Returns true if the call is within `limit`
|
||||
* for the given key over `windowSeconds`.
|
||||
|
||||
Reference in New Issue
Block a user