Skip to content

Commit 3ed6da3

Browse files
committed
refactor(auth): Refactor cookie auth implementation and update attachments controller
1 parent 3923c5a commit 3ed6da3

File tree

8 files changed

+218
-75
lines changed

8 files changed

+218
-75
lines changed

src/attachments/attachments.controller.ts

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ import { FilesInterceptor } from '@nestjs/platform-express';
1616
import { AttachmentsService } from 'omniboxd/attachments/attachments.service';
1717
import { Request, Response } from 'express';
1818
import { UserId } from 'omniboxd/decorators/user-id.decorator';
19-
import { Public } from 'omniboxd/auth/decorators/public.auth.decorator';
20-
import { Cookies } from 'omniboxd/decorators/cookie.decorators';
2119
import { AuthService } from 'omniboxd/auth/auth.service';
20+
import { CookieAuth } from 'omniboxd/auth';
2221

2322
@Controller('api/v1/attachments')
2423
export class AttachmentsController {
@@ -62,65 +61,47 @@ export class AttachmentsController {
6261
);
6362
}
6463

65-
@Public()
64+
setRedirect(req: Request, res: Response) {
65+
res
66+
.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
67+
.status(HttpStatus.FOUND)
68+
.redirect(`/user/login?redirect=${encodeURIComponent(req.url)}`);
69+
}
70+
71+
@CookieAuth({ onAuthFail: 'continue' })
6672
@Get('images/:attachmentId')
6773
async displayImage(
74+
@UserId({ optional: true }) userId: string | undefined,
6875
@Req() req: Request,
6976
@Res() res: Response,
70-
@Cookies('token') token: string,
7177
@Param('attachmentId') attachmentId: string,
7278
) {
73-
let userId = '';
74-
75-
if (token) {
76-
const payload = this.authService.jwtVerify(token);
77-
if (payload && payload.sub) {
78-
userId = payload.sub;
79-
}
80-
}
81-
82-
this.logger.debug({ userId, token, cookies: req.cookies });
8379
if (userId) {
84-
return await this.attachmentsService.displayImage(
80+
return await this.attachmentsService.displayMedia(
8581
attachmentId,
8682
userId,
8783
res,
8884
);
8985
}
90-
res
91-
.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
92-
.status(HttpStatus.FOUND)
93-
.redirect(`/user/login?redirect=${encodeURIComponent(req.url)}`);
86+
this.setRedirect(req, res);
9487
}
9588

96-
@Public()
89+
@CookieAuth({ onAuthFail: 'continue' })
9790
@Get('media/:attachmentId')
9891
async displayMedia(
92+
@UserId({ optional: true }) userId: string | undefined,
9993
@Req() req: Request,
10094
@Res() res: Response,
101-
@Cookies('token') token: string,
10295
@Param('attachmentId') attachmentId: string,
10396
) {
104-
let userId = '';
105-
106-
if (token) {
107-
const payload = this.authService.jwtVerify(token);
108-
if (payload && payload.sub) {
109-
userId = payload.sub;
110-
}
111-
}
112-
11397
if (userId) {
11498
return await this.attachmentsService.displayMedia(
11599
attachmentId,
116100
userId,
117101
res,
118102
);
119103
}
120-
res
121-
.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
122-
.status(HttpStatus.FOUND)
123-
.redirect(`/user/login?redirect=${encodeURIComponent(req.url)}`);
104+
this.setRedirect(req, res);
124105
}
125106

126107
@Delete(':attachmentId')

src/attachments/attachments.e2e-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ describe('AttachmentsController (e2e)', () => {
339339
describe('GET /api/v1/attachments/media/:attachmentId (Public)', () => {
340340
let mediaAttachmentId: string;
341341
const mediaContent = Buffer.from('fake-media-content');
342-
const mediaFilename = 'test-media.mp4';
342+
const mediaFilename = 'test-media.mp3';
343343

344344
beforeAll(async () => {
345345
// Upload a media file to test display

src/attachments/attachments.service.ts

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@ export class AttachmentsService {
162162
return { id: attachmentId, success: true };
163163
}
164164

165+
isMedia(mimetype: string): boolean {
166+
for (const type of ['image/', 'audio/']) {
167+
if (mimetype.startsWith(type)) {
168+
return true;
169+
}
170+
}
171+
return false;
172+
}
173+
165174
async displayMedia(
166175
attachmentId: string,
167176
userId: string,
@@ -179,33 +188,11 @@ export class AttachmentsService {
179188
ResourcePermission.CAN_VIEW,
180189
);
181190
await this.checkAttachment(namespaceId, resourceId, attachmentId);
191+
if (!this.isMedia(objectResponse.mimetype)) {
192+
throw new BadRequestException(attachmentId);
193+
}
182194
return objectStreamResponse(objectResponse, httpResponse, {
183195
forceDownload: false,
184196
});
185197
}
186-
187-
async displayImage(
188-
attachmentId: string,
189-
userId: string,
190-
httpResponse: Response,
191-
) {
192-
const objectResponse = await this.minioService.get(
193-
this.minioPath(attachmentId),
194-
);
195-
const { namespaceId, resourceId } = objectResponse.metadata;
196-
197-
await this.checkPermission(
198-
namespaceId,
199-
resourceId,
200-
userId,
201-
ResourcePermission.CAN_VIEW,
202-
);
203-
await this.checkAttachment(namespaceId, resourceId, attachmentId);
204-
if (objectResponse.mimetype.startsWith('image/')) {
205-
return objectStreamResponse(objectResponse, httpResponse, {
206-
forceDownload: false,
207-
});
208-
}
209-
throw new BadRequestException(attachmentId);
210-
}
211198
}

src/auth/cookie/cookie-auth.guard.spec.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('CookieAuthGuard', () => {
6262
it('should return true for non-cookie auth routes', () => {
6363
reflector.getAllAndOverride
6464
.mockReturnValueOnce(false) // isPublic = false
65-
.mockReturnValueOnce(false); // isCookieAuth = false
65+
.mockReturnValueOnce(null); // cookieAuthOptions = null
6666
const context = createMockExecutionContext();
6767

6868
const result = guard.canActivate(context);
@@ -73,7 +73,7 @@ describe('CookieAuthGuard', () => {
7373
it('should throw UnauthorizedException when no token cookie is provided', () => {
7474
reflector.getAllAndOverride
7575
.mockReturnValueOnce(false) // isPublic = false
76-
.mockReturnValueOnce(true); // isCookieAuth = true
76+
.mockReturnValueOnce({ enabled: true }); // cookieAuthOptions with default onAuthFail = 'reject'
7777
const context = createMockExecutionContext();
7878

7979
expect(() => guard.canActivate(context)).toThrow(
@@ -84,7 +84,7 @@ describe('CookieAuthGuard', () => {
8484
it('should throw UnauthorizedException when token is invalid', () => {
8585
reflector.getAllAndOverride
8686
.mockReturnValueOnce(false) // isPublic = false
87-
.mockReturnValueOnce(true); // isCookieAuth = true
87+
.mockReturnValueOnce({ enabled: true }); // cookieAuthOptions with default onAuthFail = 'reject'
8888
const context = createMockExecutionContext({ token: 'invalid-token' });
8989
authService.jwtVerify.mockImplementation(() => {
9090
throw new Error('Invalid token');
@@ -98,7 +98,7 @@ describe('CookieAuthGuard', () => {
9898
it('should throw UnauthorizedException when token payload is invalid', () => {
9999
reflector.getAllAndOverride
100100
.mockReturnValueOnce(false) // isPublic = false
101-
.mockReturnValueOnce(true); // isCookieAuth = true
101+
.mockReturnValueOnce({ enabled: true }); // cookieAuthOptions with default onAuthFail = 'reject'
102102
const context = createMockExecutionContext({ token: 'valid-token' });
103103
authService.jwtVerify.mockReturnValue({}); // No sub field
104104

@@ -110,7 +110,7 @@ describe('CookieAuthGuard', () => {
110110
it('should successfully authenticate with valid token and set cookie auth data', () => {
111111
reflector.getAllAndOverride
112112
.mockReturnValueOnce(false) // isPublic = false
113-
.mockReturnValueOnce(true); // isCookieAuth = true
113+
.mockReturnValueOnce({ enabled: true }); // cookieAuthOptions with default onAuthFail = 'reject'
114114
const context = createMockExecutionContext({ token: 'valid-token' });
115115
authService.jwtVerify.mockReturnValue({
116116
sub: 'user-123',
@@ -133,7 +133,7 @@ describe('CookieAuthGuard', () => {
133133
it('should handle token with missing optional fields', () => {
134134
reflector.getAllAndOverride
135135
.mockReturnValueOnce(false) // isPublic = false
136-
.mockReturnValueOnce(true); // isCookieAuth = true
136+
.mockReturnValueOnce({ enabled: true }); // cookieAuthOptions with default onAuthFail = 'reject'
137137
const context = createMockExecutionContext({ token: 'valid-token' });
138138
authService.jwtVerify.mockReturnValue({
139139
sub: 'user-123',
@@ -150,4 +150,41 @@ describe('CookieAuthGuard', () => {
150150
username: undefined,
151151
});
152152
});
153+
154+
it('should continue without authentication when onAuthFail is continue and no token', () => {
155+
reflector.getAllAndOverride
156+
.mockReturnValueOnce(false) // isPublic = false
157+
.mockReturnValueOnce({ enabled: true, onAuthFail: 'continue' }); // cookieAuthOptions with onAuthFail = 'continue'
158+
const context = createMockExecutionContext();
159+
160+
const result = guard.canActivate(context);
161+
162+
expect(result).toBe(true);
163+
});
164+
165+
it('should continue without authentication when onAuthFail is continue and token is invalid', () => {
166+
reflector.getAllAndOverride
167+
.mockReturnValueOnce(false) // isPublic = false
168+
.mockReturnValueOnce({ enabled: true, onAuthFail: 'continue' }); // cookieAuthOptions with onAuthFail = 'continue'
169+
const context = createMockExecutionContext({ token: 'invalid-token' });
170+
authService.jwtVerify.mockImplementation(() => {
171+
throw new Error('Invalid token');
172+
});
173+
174+
const result = guard.canActivate(context);
175+
176+
expect(result).toBe(true);
177+
});
178+
179+
it('should continue without authentication when onAuthFail is continue and token payload is invalid', () => {
180+
reflector.getAllAndOverride
181+
.mockReturnValueOnce(false) // isPublic = false
182+
.mockReturnValueOnce({ enabled: true, onAuthFail: 'continue' }); // cookieAuthOptions with onAuthFail = 'continue'
183+
const context = createMockExecutionContext({ token: 'valid-token' });
184+
authService.jwtVerify.mockReturnValue({}); // No sub field
185+
186+
const result = guard.canActivate(context);
187+
188+
expect(result).toBe(true);
189+
});
153190
});

src/auth/cookie/cookie-auth.guard.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
2-
Injectable,
32
CanActivate,
43
ExecutionContext,
4+
Injectable,
55
UnauthorizedException,
66
} from '@nestjs/common';
77
import { Reflector } from '@nestjs/core';
88
import { AuthService } from 'omniboxd/auth/auth.service';
99
import { IS_COOKIE_AUTH, IS_PUBLIC_KEY } from 'omniboxd/auth/decorators';
10+
import { CookieAuthOptions } from 'omniboxd/auth/cookie/cookie.auth.decorator';
1011

1112
@Injectable()
1213
export class CookieAuthGuard implements CanActivate {
@@ -25,19 +26,22 @@ export class CookieAuthGuard implements CanActivate {
2526
return true;
2627
}
2728

28-
const isCookieAuth = this.reflector.getAllAndOverride<boolean>(
29-
IS_COOKIE_AUTH,
30-
[context.getHandler(), context.getClass()],
31-
);
29+
const cookieAuthOptions = this.reflector.getAllAndOverride<
30+
CookieAuthOptions & { enabled: boolean }
31+
>(IS_COOKIE_AUTH, [context.getHandler(), context.getClass()]);
3232

33-
if (!isCookieAuth) {
33+
if (!cookieAuthOptions?.enabled) {
3434
return true; // Let other guards handle non-cookie routes
3535
}
3636

3737
const request = context.switchToHttp().getRequest();
3838
const token = request.cookies?.token;
39+
const onAuthFail = cookieAuthOptions.onAuthFail || 'reject';
3940

4041
if (!token) {
42+
if (onAuthFail === 'continue') {
43+
return true; // Continue without authentication
44+
}
4145
throw new UnauthorizedException(
4246
'Authentication token cookie is required',
4347
);
@@ -47,6 +51,9 @@ export class CookieAuthGuard implements CanActivate {
4751
const payload = this.authService.jwtVerify(token);
4852

4953
if (!payload.sub) {
54+
if (onAuthFail === 'continue') {
55+
return true; // Continue without authentication
56+
}
5057
throw new UnauthorizedException('Invalid token payload');
5158
}
5259

@@ -59,6 +66,10 @@ export class CookieAuthGuard implements CanActivate {
5966

6067
return true;
6168
} catch (error) {
69+
if (onAuthFail === 'continue') {
70+
return true; // Continue without authentication
71+
}
72+
6273
// Re-throw UnauthorizedException with original message
6374
if (error instanceof UnauthorizedException) {
6475
throw error;
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { SetMetadata } from '@nestjs/common';
22

3+
export interface CookieAuthOptions {
4+
onAuthFail?: 'reject' | 'continue';
5+
}
6+
37
export const IS_COOKIE_AUTH = 'isCookieAuth';
4-
export const CookieAuth = () => SetMetadata(IS_COOKIE_AUTH, true);
8+
export const CookieAuth = (options: CookieAuthOptions = {}) =>
9+
SetMetadata(IS_COOKIE_AUTH, { enabled: true, ...options });

0 commit comments

Comments
 (0)