Skip to content

Commit d3fd57d

Browse files
authored
[AvatarController] Restrict UploadAvatar to current user (#3839)
1 parent 919afe0 commit d3fd57d

File tree

4 files changed

+78
-27
lines changed

4 files changed

+78
-27
lines changed

Backend.Tests/Controllers/AvatarControllerTests.cs

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ protected virtual void Dispose(bool disposing)
3232
}
3333

3434
private User _jwtAuthenticatedUser = null!;
35+
private const string FileName = "combine.png"; // File in Backend.Tests/Assets/
36+
private readonly string _filePath = Path.Combine(Util.AssetsDir, FileName);
3537

3638
[SetUp]
3739
public void Setup()
@@ -48,6 +50,7 @@ public void Setup()
4850
_userRepo.Create(_jwtAuthenticatedUser);
4951
_jwtAuthenticatedUser = _permissionService.Authenticate(_jwtAuthenticatedUser.Username,
5052
_jwtAuthenticatedUser.Password).Result ?? throw new UserAuthenticationException();
53+
_avatarController.ControllerContext.HttpContext.Request.Headers["UserId"] = _jwtAuthenticatedUser.Id;
5154
}
5255

5356
/// <summary> Delete the image file stored on disk for a particular user. </summary>
@@ -62,19 +65,67 @@ private static void DeleteAvatarFile(string userId)
6265
}
6366

6467
[Test]
65-
public void TestAvatarImport()
68+
public void TestDownloadAvatarNoUser()
6669
{
67-
const string fileName = "combine.png"; // file in Backend.Tests/Assets/
68-
var filePath = Path.Combine(Util.AssetsDir, fileName);
69-
using var stream = File.OpenRead(filePath);
70-
var file = new FormFile(stream, 0, stream.Length, "dave", fileName);
70+
var result = _avatarController.DownloadAvatar("false-user-id").Result;
71+
Assert.That(result, Is.InstanceOf<NotFoundResult>());
72+
}
73+
74+
[Test]
75+
public void TestDownloadAvatarNoAvatar()
76+
{
77+
var result = _avatarController.DownloadAvatar(_jwtAuthenticatedUser.Id).Result;
78+
Assert.That(result, Is.InstanceOf<NotFoundResult>());
79+
}
80+
81+
[Test]
82+
public void TestUploadAvatarUnauthorizedUser()
83+
{
84+
using var stream = File.OpenRead(_filePath);
85+
var file = new FormFile(stream, 0, stream.Length, "formFileName", FileName);
86+
_avatarController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();
7187

72-
_ = _avatarController.UploadAvatar(_jwtAuthenticatedUser.Id, file).Result;
88+
var result = _avatarController.UploadAvatar(file).Result;
89+
Assert.That(result, Is.InstanceOf<ForbidResult>());
90+
}
91+
92+
[Test]
93+
public void TestUploadAudioFileNullFile()
94+
{
95+
var result = _avatarController.UploadAvatar(null).Result;
96+
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
97+
}
98+
99+
[Test]
100+
public void TestUploadAudioFileEmptyFile()
101+
{
102+
using var stream = File.OpenRead(_filePath);
103+
// Use 0 for the third argument to simulate an empty file.
104+
var file = new FormFile(stream, 0, 0, "formFileName", FileName);
105+
106+
var result = _avatarController.UploadAvatar(file).Result;
107+
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
108+
}
109+
110+
[Test]
111+
public void TestUploadAvatarAndDownloadAvatar()
112+
{
113+
using var stream = File.OpenRead(_filePath);
114+
var file = new FormFile(stream, 0, stream.Length, "formFileName", FileName);
115+
var uploadResult = _avatarController.UploadAvatar(file).Result;
116+
Assert.That(uploadResult, Is.TypeOf<OkResult>());
73117

74118
var foundUser = _userRepo.GetUser(_jwtAuthenticatedUser.Id).Result;
75119
Assert.That(foundUser?.Avatar, Is.Not.Null);
76120

121+
// No permissions should be required to download an avatar.
122+
_avatarController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();
123+
124+
var fileResult = _avatarController.DownloadAvatar(_jwtAuthenticatedUser.Id).Result as FileStreamResult;
125+
Assert.That(fileResult, Is.TypeOf<FileStreamResult>());
126+
77127
// Clean up.
128+
fileResult!.FileStream.Dispose();
78129
DeleteAvatarFile(_jwtAuthenticatedUser.Id);
79130
}
80131
}

Backend/Controllers/AvatarController.cs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,34 @@ public AvatarController(IUserRepository userRepo, IPermissionService permissionS
2525

2626
/// <summary> Get user's avatar on disk </summary>
2727
/// <returns> Stream of local avatar file </returns>
28+
[AllowAnonymous]
2829
[HttpGet("download", Name = "DownloadAvatar")]
2930
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileContentResult))]
31+
[ProducesResponseType(StatusCodes.Status404NotFound)]
3032
public async Task<IActionResult> DownloadAvatar(string userId)
3133
{
32-
// SECURITY: Omitting authentication so the frontend can use the API endpoint directly as a URL.
33-
// if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry))
34-
// {
35-
// return Forbid();
36-
// }
37-
38-
var user = await _userRepo.GetUser(userId, false);
39-
var avatar = string.IsNullOrEmpty(user?.Avatar) ? null : user.Avatar;
40-
41-
if (avatar is null)
34+
var avatar = (await _userRepo.GetUser(userId, false))?.Avatar;
35+
if (string.IsNullOrEmpty(avatar))
4236
{
43-
return NotFound(userId);
37+
return NotFound();
4438
}
4539

4640
var imageFile = System.IO.File.OpenRead(avatar);
4741
return File(imageFile, "application/octet-stream");
4842
}
4943

5044
/// <summary>
51-
/// Adds an avatar image to a <see cref="User"/> and saves locally to ~/.CombineFiles/{ProjectId}/Avatars
45+
/// Adds an avatar image to current <see cref="User"/> and saves locally to ~/.CombineFiles/{ProjectId}/Avatars
5246
/// </summary>
5347
/// <returns> Path to local avatar file </returns>
5448
[HttpPost("upload", Name = "UploadAvatar")]
5549
[ProducesResponseType(StatusCodes.Status200OK)]
56-
public async Task<IActionResult> UploadAvatar(string userId, IFormFile? file)
50+
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(string))]
51+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
52+
[ProducesResponseType(StatusCodes.Status404NotFound)]
53+
public async Task<IActionResult> UploadAvatar(IFormFile? file)
5754
{
58-
if (!_permissionService.IsUserIdAuthorized(HttpContext, userId))
55+
if (!_permissionService.IsCurrentUserAuthorized(HttpContext))
5956
{
6057
return Forbid();
6158
}
@@ -72,10 +69,11 @@ public async Task<IActionResult> UploadAvatar(string userId, IFormFile? file)
7269
}
7370

7471
// Get user to apply avatar to.
72+
var userId = _permissionService.GetUserId(HttpContext);
7573
var user = await _userRepo.GetUser(userId, false);
7674
if (user is null)
7775
{
78-
return NotFound(userId);
76+
return NotFound();
7977
}
8078

8179
// Generate path to store avatar file.

src/backend/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,13 @@ export function getAudioUrl(wordId: string, fileName: string): string {
169169

170170
/* AvatarController.cs */
171171

172-
export async function uploadAvatar(userId: string, file: File): Promise<void> {
172+
/** Uploads avatar for current user. */
173+
export async function uploadAvatar(file: File): Promise<void> {
174+
// Backend ignores userId and gets current user from HttpContext,
175+
// but userId is still required in the url and helpful for analytics.
176+
const userId = LocalStorage.getUserId();
173177
await avatarApi.uploadAvatar({ userId, file }, fileUploadOptions());
174-
if (userId === LocalStorage.getUserId()) {
175-
LocalStorage.setAvatar(await avatarSrc(userId));
176-
}
178+
LocalStorage.setAvatar(await avatarSrc(userId));
177179
}
178180

179181
/** Returns the string to display the image inline in Base64 <img src= */

src/components/UserSettings/ClickableAvatar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Avatar } from "@mui/material";
33
import { ReactElement, useState } from "react";
44

55
import { uploadAvatar } from "backend";
6-
import { getAvatar, getUserId } from "backend/localStorage";
6+
import { getAvatar } from "backend/localStorage";
77
import { UploadImageDialog } from "components/Dialogs";
88

99
const avatarStyle = { height: 60, width: 60 };
@@ -53,7 +53,7 @@ export default function ClickableAvatar(
5353
close={closeDialog}
5454
open={avatarDialogOpen}
5555
titleId="userSettings.uploadAvatarTitle"
56-
uploadImage={(imgFile: File) => uploadAvatar(getUserId(), imgFile)}
56+
uploadImage={(imgFile: File) => uploadAvatar(imgFile)}
5757
/>
5858
</>
5959
);

0 commit comments

Comments
 (0)