Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RegisterAuthDto } from './dto/register-auth.dto';
import { LoginAuthDto } from './dto/login-auth.dto';
import { RefreshAuthDto } from './dto/refresh-auth.dto';
import { getFrontendUrl } from '../common/cors.config';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Controller('auth')
export class AuthController {
Expand All @@ -42,6 +43,17 @@ export class AuthController {
return this.authService.refreshToken(dto);
}

@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request) {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.replace('Bearer ', '');
await this.authService.logout(token);
}
return { message: 'Logged out successfully' };
}

@Get('google')
@UseGuards(AuthGuard('google'))
googleAuth() {
Expand Down
130 changes: 130 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { ConflictException, UnauthorizedException } from '@nestjs/common';

const mockUser = {
id: 'user-1',
email: 'test@example.com',
passwordHash: '$2b$12$hashedpassword',
fullName: 'Test User',
role: 'user' as const,
isVerified: true,
};

const mockUsersService = {
findByEmail: jest.fn(),
create: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
changeEmail: jest.fn(),
};

const mockJwtService = {
signAsync: jest.fn().mockResolvedValue('mock-token'),
verifyAsync: jest.fn(),
decode: jest.fn(),
};

describe('AuthService', () => {
let service: AuthService;

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: JwtService, useValue: mockJwtService },
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
if (key === 'JWT_SECRET') return 'test-secret';
return undefined;
}),
},
},
],
}).compile();

service = module.get<AuthService>(AuthService);
});

describe('handleOAuthLogin()', () => {
it('should create a new user if no existing user with same email', async () => {
mockUsersService.findByEmail.mockResolvedValue(null);
mockUsersService.create.mockResolvedValue({
...mockUser,
passwordHash: null,
});

const result = await service.handleOAuthLogin(
'new@example.com',
'New User',
);
expect(mockUsersService.create).toHaveBeenCalledWith({
email: 'new@example.com',
fullName: 'New User',
passwordHash: null,
role: 'user',
isVerified: true,
});
expect(result.access_token).toBe('mock-token');
});

it('should link OAuth to existing OAuth-only account (no password)', async () => {
const existingOAuthUser = { ...mockUser, passwordHash: null };
mockUsersService.findByEmail.mockResolvedValue(existingOAuthUser);

const result = await service.handleOAuthLogin(
'test@example.com',
'Test User',
);
expect(result.access_token).toBe('mock-token');
expect(mockUsersService.create).not.toHaveBeenCalled();
});

it('should reject OAuth login if email matches a password-based account', async () => {
mockUsersService.findByEmail.mockResolvedValue(mockUser);

await expect(
service.handleOAuthLogin('test@example.com', 'Test User'),
).rejects.toThrow(ConflictException);
});

it('should throw BadRequestException if no email is provided', async () => {
await expect(
service.handleOAuthLogin('', 'Test User'),
).rejects.toThrow('Email is required');
});
});

describe('logout()', () => {
it('should add token to blacklist', async () => {
mockJwtService.decode.mockReturnValue({
sub: 'user-1',
exp: Math.floor(Date.now() / 1000) + 3600,
});

await service.logout('test-token');
expect(service.isTokenBlacklisted('test-token')).toBe(true);
});

it('should handle malformed tokens gracefully', async () => {
mockJwtService.decode.mockImplementation(() => {
throw new Error('invalid');
});

await expect(service.logout('bad-token')).resolves.toBeUndefined();
});
});

describe('isTokenBlacklisted()', () => {
it('should return false for non-blacklisted tokens', () => {
expect(service.isTokenBlacklisted('nonexistent-token')).toBe(false);
});
});
});
29 changes: 28 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { User, UserRole } from '../users/entities/user.entity';

@Injectable()
export class AuthService {
private readonly tokenBlacklist = new Set<string>();

constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
Expand Down Expand Up @@ -57,7 +59,13 @@ export class AuthService {
}

let user = await this.usersService.findByEmail(email);
if (!user) {
if (user) {
if (user.passwordHash) {
throw new ConflictException(
'An account with this email already exists. Please log in with your password.',
);
}
} else {
user = await this.usersService.create({
email,
fullName: fullName || email,
Expand Down Expand Up @@ -97,6 +105,25 @@ export class AuthService {
}
}

async logout(accessToken: string): Promise<void> {
try {
const payload = this.jwtService.decode<JwtPayload>(accessToken);
if (payload?.exp) {
const ttl = payload.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
this.tokenBlacklist.add(accessToken);
setTimeout(() => this.tokenBlacklist.delete(accessToken), ttl * 1000);
}
}
} catch {
// Token is malformed, nothing to blacklist
}
}

isTokenBlacklisted(token: string): boolean {
return this.tokenBlacklist.has(token);
}

private async validateCredentials(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) {
Expand Down
74 changes: 74 additions & 0 deletions backend/src/auth/guards/roles.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';

describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;

const mockContext = (user?: any): ExecutionContext => ({
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as any);

beforeEach(() => {
reflector = new Reflector();
guard = new RolesGuard(reflector, null as any, null as any);
});

it('should allow access when no roles are required', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
expect(await guard.canActivate(mockContext({ role: 'user' }))).toBe(true);
});

it('should allow access when user has the required role', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = mockContext({ role: 'admin' });
expect(await guard.canActivate(context)).toBe(true);
});

it('should deny access when user does not have the required role', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = mockContext({ role: 'user' });
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});

it('should allow access when user has one of multiple required roles', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['admin', 'moderator']);
const context = mockContext({ role: 'moderator' });
expect(await guard.canActivate(context)).toBe(true);
});

it('should deny access when user has none of the required roles', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['admin', 'moderator']);
const context = mockContext({ role: 'user' });
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});

it('should deny access when user is not authenticated', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = mockContext(undefined);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});

it('should deny access when user has unrelated roles', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = mockContext({ role: 'editor' });
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});
});
2 changes: 2 additions & 0 deletions backend/src/auth/interfaces/jwt-payload.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export interface JwtPayload {
sub: string;
email: string;
role: UserRole;
exp?: number;
iat?: number;
}
10 changes: 9 additions & 1 deletion backend/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@ import { ExtractJwt, Strategy } from 'passport-jwt';

import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { UsersService } from '../../users/users.service';
import { AuthService } from '../auth.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_SECRET'),
ignoreExpiration: false,
passReqToCallback: true,
});
}

async validate(payload: JwtPayload) {
async validate(request: any, payload: JwtPayload) {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (token && this.authService.isTokenBlacklisted(token)) {
throw new UnauthorizedException('Token has been revoked');
}

const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
Expand Down
20 changes: 20 additions & 0 deletions backend/src/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,27 @@ export class MailService {
});
}

async sendVerificationEmail(to: string, token: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:6004';
const verificationUrl = `${appUrl}/api/auth/verify-email?token=${token}`;

await this.sendMail({
to,
subject: 'Verify your new email address',
text: `Please verify your new email address by clicking the following link:\n\n${verificationUrl}\n\nIf you did not request this change, please ignore this email.`,
html: `
<p>Please verify your new email address by clicking the link below:</p>
<p><a href="${verificationUrl}">Verify Email Address</a></p>
<p>If you did not request this change, please ignore this email.</p>
`,
});
}

async sendWelcome(to: string, name: string): Promise<void> {
await this.sendMail({
to,
subject: 'Welcome to Smalda',
text: `Hi ${name},\n\nThank you for joining Smalda. We are excited to help you secure your land documents.`,
html: `<p>Hi ${name},</p><p>Thank you for joining Smalda. We are excited to help you secure your land documents.</p>`,
});
}
Expand All @@ -71,6 +88,7 @@ export class MailService {
await this.sendMail({
to,
subject: 'Document Verification Complete',
text: `Your document "${documentTitle}" has been anchored on the Stellar network.\n\nTransaction hash: ${txHash}\n\nYou can view the transaction via the Stellar Horizon explorer.`,
html: `
<p>Your document <strong>${documentTitle}</strong> has been anchored on the Stellar network.</p>
<p>Transaction hash: <code>${txHash}</code></p>
Expand All @@ -91,9 +109,11 @@ export class MailService {
}

const flagList = flags.map((flag) => `<li>${flag}</li>`).join('');
const flagText = flags.map((flag) => ` - ${flag}`).join('\n');
await this.sendMail({
to,
subject: 'Risk Alert: Document Needs Attention',
text: `The document "${documentTitle}" triggered the following risk flags:\n\n${flagText}\n\nPlease review the document and supply any missing information.`,
html: `
<p>The document <strong>${documentTitle}</strong> triggered the following risk flags:</p>
<ul>${flagList}</ul>
Expand Down
18 changes: 17 additions & 1 deletion backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
Expand Down Expand Up @@ -27,6 +27,22 @@ export class UsersService {
return this.findById(id);
}

async changeEmail(
id: string,
newEmail: string,
): Promise<User | null> {
const existing = await this.findByEmail(newEmail);
if (existing && existing.id !== id) {
return null;
}

await this.userRepository.update(id, {
email: newEmail,
isVerified: false,
});
return this.findById(id);
}

async softDelete(id: string): Promise<void> {
await this.userRepository.softDelete(id);
}
Expand Down