Skip to content

Commit

Permalink
Login Endpoint (#169)
Browse files Browse the repository at this point in the history
* Move controller action and define validator

* Tidy Validator and Action

* Add tests

* Formatting

* Fix routes in tests

* Add 403 case

* Add 400 case

* Remove old user tests

* Address comments

* Address comments

* Remove my dumbass variable assignment

* Address comments

* Shift business logic into service

* Change route paths to internal consts

* Use OneOf better

* Keep primitive type for login tokens

* Remove unneeded IAuthService in controller
  • Loading branch information
zysim authored Aug 13, 2023
1 parent 2902565 commit c5b7b57
Show file tree
Hide file tree
Showing 14 changed files with 362 additions and 240 deletions.
7 changes: 7 additions & 0 deletions LeaderboardBackend.Test/Consts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace LeaderboardBackend.Test;

internal static class Routes
{
public const string LOGIN = "/login";
public const string REGISTER = "/account/register";
}
126 changes: 126 additions & 0 deletions LeaderboardBackend.Test/Features/Users/LoginTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Threading.Tasks;
using LeaderboardBackend.Authorization;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Test.Fixtures;
using LeaderboardBackend.Test.Lib;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NUnit.Framework;

namespace LeaderboardBackend.Test.Features.Users;

public class LoginTests : IntegrationTestsBase
{
[OneTimeSetUp]
public void Init()
{
s_factory.ResetDatabase();

// TODO: Swap to creating users via the UserService instead of calling the DB, once
// it has the ability to change a user's roles.
using IServiceScope s = s_factory.Services.CreateScope();
ApplicationContext dbContext = s.ServiceProvider.GetRequiredService<ApplicationContext>();
dbContext.Users.AddRange(new[]
{
new User{
Email = "[email protected]",
Password = BCrypt.Net.BCrypt.EnhancedHashPassword("P4ssword"),
Username = "Test_User",
},
new User{
Email = "[email protected]",
Password = BCrypt.Net.BCrypt.EnhancedHashPassword("P4ssword"),
Role = UserRole.Banned,
Username = "Banned_User",
},
});
dbContext.SaveChanges();
}

[Test]
public async Task Login_ValidRequest_ReturnsLoginResponse()
{
LoginRequest request = new()
{
Email = TestInitCommonFields.Admin.Email,
Password = TestInitCommonFields.Admin.Password,
};

HttpResponseMessage res = await Client.PostAsJsonAsync(Routes.LOGIN, request);

res.Should().HaveStatusCode(HttpStatusCode.OK);
LoginResponse? content = await res.Content.ReadFromJsonAsync<LoginResponse>();
content.Should().NotBeNull();

using IServiceScope s = s_factory.Services.CreateScope();
JwtConfig jwtConfig = s.ServiceProvider.GetRequiredService<IOptions<JwtConfig>>().Value;
TokenValidationParameters parameters = Jwt.ValidationParameters.GetInstance(jwtConfig);

Jwt.SecurityTokenHandler.ValidateToken(content!.Token, parameters, out _).Should().BeOfType<ClaimsPrincipal>();
}

[TestCase(null, null, "NotNullValidator", "NotEmptyValidator", Description = "Null email + password")]
[TestCase("ee", "ff", "EmailValidator", null, Description = "Invalid email + password")]
[TestCase("ee", "P4ssword", "EmailValidator", null, Description = "Null email + valid password")]
public async Task Login_InvalidRequest_Returns422(
string? email,
string? password,
string? emailErrorCode,
string? passwordErrorCode)
{
LoginRequest request = new()
{
Email = email!,
Password = password!,
};

HttpResponseMessage res = await Client.PostAsJsonAsync(Routes.LOGIN, request);

res.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
ValidationProblemDetails? content = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>();
content.Should().NotBeNull();
if (emailErrorCode is not null)
{
content!.Errors[nameof(LoginRequest.Email)].Should().Equal(emailErrorCode);
}

if (passwordErrorCode is not null)
{
content!.Errors[nameof(LoginRequest.Password)].Should().Equal(passwordErrorCode);
}
}

[Test]
public async Task Login_InvalidRequest_Returns400()
{
HttpResponseMessage res = await Client.PostAsync(
Routes.LOGIN,
new StringContent("\"", new MediaTypeHeaderValue("application/json"))
);
res.Should().HaveStatusCode(HttpStatusCode.BadRequest);
}

[TestCase("[email protected]", "Inc0rrectPassword", HttpStatusCode.Unauthorized, Description = "Wrong password")]
[TestCase("[email protected]", "Inc0rrectPassword", HttpStatusCode.Forbidden, Description = "Banned user")]
[TestCase("[email protected]", "Inc0rrectPassword", HttpStatusCode.NotFound, Description = "Wrong email")]
public async Task Login_InvalidRequest_OtherErrors(string email, string password, HttpStatusCode statusCode)
{
LoginRequest request = new()
{
Email = email,
Password = password,
};

HttpResponseMessage res = await Client.PostAsJsonAsync(Routes.LOGIN, request);

res.Should().HaveStatusCode(statusCode);
}
}
11 changes: 10 additions & 1 deletion LeaderboardBackend.Test/Features/Users/UserPasswordRuleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ public void InvalidPasswordFormat(string password)
.WithErrorCode(UserPasswordRule.PASSWORD_FORMAT);
}

private record ExampleObject(string Password);
[Test]
public void NullPassword()
{
TestValidationResult<ExampleObject> result = _sut.TestValidate(new ExampleObject());

result.ShouldHaveValidationErrorFor(x => x.Password)
.WithErrorCode("NotNullValidator");
}

private record ExampleObject(string? Password = null);

private class TestValidator : AbstractValidator<ExampleObject>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ string password
)
{
return await client.Post<UserViewModel>(
"/account/register",
Routes.REGISTER,
new()
{
Body = new RegisterRequest()
Expand All @@ -35,7 +35,7 @@ string password
)
{
return await apiClient.Post<LoginResponse>(
"/api/users/login",
Routes.LOGIN,
new()
{
Body = new LoginRequest() { Email = email, Password = password, }
Expand All @@ -46,7 +46,7 @@ string password
public static async Task<LoginResponse> LoginAdminUser(this TestApiClient apiClient)
{
return await apiClient.Post<LoginResponse>(
"/api/users/login",
Routes.LOGIN,
new()
{
Body = new LoginRequest()
Expand Down
94 changes: 0 additions & 94 deletions LeaderboardBackend.Test/Users.cs

This file was deleted.

60 changes: 50 additions & 10 deletions LeaderboardBackend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
using LeaderboardBackend.Controllers.Annotations;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Models.ViewModels;
using LeaderboardBackend.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using BCryptNet = BCrypt.Net.BCrypt;

namespace LeaderboardBackend.Controllers;

Expand All @@ -30,6 +28,14 @@ public AccountController(IUserService userService)
/// <response code="400">
/// The request was malformed.
/// </response>
/// <response code="409">
/// A `User` with the specified username or email already exists.<br/><br/>
/// Validation error codes by property:
/// - **Username**:
/// - **UsernameTaken**: the username is already in use
/// - **Email**:
/// - **EmailAlreadyUsed**: the email is already in use
/// </response>
/// <response code="422">
/// The request contains errors.<br/><br/>
/// Validation error codes by property:
Expand All @@ -40,14 +46,6 @@ public AccountController(IUserService userService)
/// - **Email**:
/// - **EmailValidator**: Invalid email format
/// </response>
/// <response code="409">
/// A `User` with the specified username or email already exists.<br/><br/>
/// Validation error codes by property:
/// - **Username**:
/// - **UsernameTaken**: the username is already in use
/// - **Email**:
/// - **EmailAlreadyUsed**: the email is already in use
/// </response>
[AllowAnonymous]
[HttpPost("register")]
[ApiConventionMethod(typeof(Conventions), nameof(Conventions.PostAnon))]
Expand All @@ -74,4 +72,46 @@ public async Task<ActionResult<UserViewModel>> Register([FromBody] RegisterReque
return Conflict(new ValidationProblemDetails(ModelState));
});
}

/// <summary>
/// Logs a User in.
/// </summary>
/// <param name="request">
/// The `LoginRequest` instance from which to perform the login.
/// </param>
/// <response code="200">
/// The `User` was logged in successfully. A `LoginResponse` is returned, containing a token.
/// </response>
/// <response code="400">The request was malformed.</response>
/// <response code="401">The password given was incorrect.</response>
/// <response code="403">The associated `User` is banned.</response>
/// <response code="404">No `User` with the requested details could be found.</response>
/// <response code="422">
/// The request contains errors.<br/><br/>
/// Validation error codes by property:
/// - **Password**:
/// - **NotEmptyValidator**: No password was passed
/// - **PasswordFormat**: Invalid password format
/// - **Email**:
/// - **NotNullValidator**: No email was passed
/// - **EmailValidator**: Invalid email format
/// </response>
[AllowAnonymous]
[HttpPost("/login")]
[ApiConventionMethod(typeof(Conventions), nameof(Conventions.PostAnon))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
{
LoginResult result = await _userService.LoginByEmailAndPassword(request.Email, request.Password);

return result.Match<ActionResult<LoginResponse>>(
loginToken => Ok(new LoginResponse { Token = loginToken }),
notFound => NotFound(),
banned => Forbid(),
badCredentials => Unauthorized()
);
}
}
1 change: 0 additions & 1 deletion LeaderboardBackend/Controllers/Annotations/Conventions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ params object[] parameters
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ValidationProblemDetails))]
public static void Post(params object[] parameters) { }

[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ValidationProblemDetails))]
Expand Down
Loading

0 comments on commit c5b7b57

Please sign in to comment.