diff --git a/HopFrame.sln b/HopFrame.sln
index 94da369..b5779b1 100644
--- a/HopFrame.sln
+++ b/HopFrame.sln
@@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security.Tests", "tests\HopFrame.Security.Tests\HopFrame.Security.Tests.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api.Tests", "tests\HopFrame.Api.Tests\HopFrame.Api.Tests.csproj", "{25DE1510-47E5-46FF-89A4-B9F99542218E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -66,6 +68,10 @@ Global
{6747753A-6059-48F1-B779-D73765A373A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {25DE1510-47E5-46FF-89A4-B9F99542218E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {25DE1510-47E5-46FF-89A4-B9F99542218E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427}
@@ -77,5 +83,6 @@ Global
{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {EEA20D27-D471-44AF-A287-C0E068D93182}
{1CAAC943-B8FE-48DD-9712-92699647DE18} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A}
{6747753A-6059-48F1-B779-D73765A373A6} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A}
+ {25DE1510-47E5-46FF-89A4-B9F99542218E} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A}
EndGlobalSection
EndGlobal
diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user
index 4f1a50c..ea6ada4 100644
--- a/HopFrame.sln.DotSettings.user
+++ b/HopFrame.sln.DotSettings.user
@@ -2,6 +2,7 @@
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
@@ -16,11 +17,18 @@
- <SessionState ContinuousTestingMode="0" Name="Authentication_With_NoToken_Should_Fail" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
- <TestAncestor>
- <TestId>xUnit::6747753A-6059-48F1-B779-D73765A373A6::net8.0::HopFrame.Security.Tests.AuthenticationTests.Authentication_With_NoToken_Should_Fail</TestId>
- </TestAncestor>
-</SessionState>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HopFrame.Api/HopFrame.Api.csproj b/src/HopFrame.Api/HopFrame.Api.csproj
index 744a466..c8b21c4 100644
--- a/src/HopFrame.Api/HopFrame.Api.csproj
+++ b/src/HopFrame.Api/HopFrame.Api.csproj
@@ -22,4 +22,10 @@
+
+
+ <_Parameter1>HopFrame.Api.Tests
+
+
+
diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
index e792add..c1f3c90 100644
--- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
+++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http;
namespace HopFrame.Api.Logic.Implementation;
-public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic {
+internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic {
public async Task>> Login(UserLogin login) {
var user = await users.GetUserByEmail(login.Email);
@@ -38,7 +38,7 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon
public async Task>> Register(UserRegister register) {
if (register.Password.Length < 8)
- return LogicResult>.Conflict("Password needs to be at least 8 characters long");
+ return LogicResult>.BadRequest("Password needs to be at least 8 characters long");
var allUsers = await users.GetUsers();
if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email))
@@ -71,18 +71,18 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
- return LogicResult>.Conflict("Refresh token not provided");
+ return LogicResult>.BadRequest("Refresh token not provided");
var token = await tokens.GetToken(refreshToken);
- if (token.Type != Token.RefreshTokenType)
- return LogicResult>.BadRequest("The provided token is not a refresh token");
-
if (token is null)
return LogicResult>.NotFound("Refresh token not valid");
+ if (token.Type != Token.RefreshTokenType)
+ return LogicResult>.Conflict("The provided token is not a refresh token");
+
if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now)
- return LogicResult>.Conflict("Refresh token is expired");
+ return LogicResult>.Forbidden("Refresh token is expired");
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
diff --git a/tests/HopFrame.Api.Tests/AuthLogicTests.cs b/tests/HopFrame.Api.Tests/AuthLogicTests.cs
new file mode 100644
index 0000000..ae82b65
--- /dev/null
+++ b/tests/HopFrame.Api.Tests/AuthLogicTests.cs
@@ -0,0 +1,403 @@
+using System.Net;
+using System.Security.Claims;
+using HopFrame.Api.Logic;
+using HopFrame.Api.Logic.Implementation;
+using HopFrame.Api.Models;
+using HopFrame.Api.Tests.Extensions;
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+using HopFrame.Security.Authentication;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Models;
+using Microsoft.AspNetCore.Http;
+using Moq;
+
+namespace HopFrame.Api.Tests;
+
+public class AuthLogicTests {
+
+ private readonly Guid _refreshToken = Guid.NewGuid();
+ private readonly Guid _accessToken = Guid.NewGuid();
+
+ private (IAuthLogic, HttpContext) SetupEnvironment(bool passwordIsCorrect = true, Token providedRefreshToken = null, string providedTokenCookie = null, bool provideAccessToken = true) {
+ var accessor = new HttpContextAccessor {
+ HttpContext = new DefaultHttpContext()
+ };
+
+ if (providedTokenCookie != null) {
+ var cookies = new Mock();
+ cookies
+ .SetupGet(c => c[ITokenContext.RefreshTokenType])
+ .Returns(providedTokenCookie);
+ accessor.HttpContext.Request.Cookies = cookies.Object;
+ }
+
+ if (provideAccessToken) {
+ var claims = new List {
+ new(HopFrameClaimTypes.AccessTokenId, _accessToken.ToString())
+ };
+ accessor.HttpContext.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
+ }
+
+ var users = new Mock();
+ users
+ .Setup(u => u.GetUserByEmail(It.Is(email => CreateDummyUser().Email == email)))
+ .ReturnsAsync(CreateDummyUser());
+ users
+ .Setup(u => u.CheckUserPassword(It.Is(u => u.Email == CreateDummyUser().Email), It.IsAny()))
+ .ReturnsAsync(passwordIsCorrect);
+ users
+ .Setup(u => u.AddUser(It.IsAny()))
+ .ReturnsAsync(CreateDummyUser());
+ users
+ .Setup(u => u.GetUsers())
+ .ReturnsAsync(new List { CreateDummyUser() });
+
+ var tokens = new Mock();
+ tokens
+ .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny()))
+ .ReturnsAsync(new Token {
+ Content = _refreshToken,
+ Type = Token.RefreshTokenType
+ });
+ tokens
+ .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny()))
+ .ReturnsAsync(new Token {
+ Content = _accessToken,
+ Type = Token.AccessTokenType
+ });
+ tokens
+ .Setup(t => t.GetToken(It.Is(token => token == _refreshToken.ToString())))
+ .ReturnsAsync(providedRefreshToken);
+
+ var context = new Mock();
+ context
+ .Setup(c => c.User)
+ .Returns(CreateDummyUser());
+
+ return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor), accessor.HttpContext);
+ }
+
+ private User CreateDummyUser() => new() {
+ Id = Guid.NewGuid(),
+ CreatedAt = DateTime.Now,
+ Email = "test@example.com",
+ Username = "ExampleUser",
+ Password = "1234567890"
+ };
+
+ [Fact]
+ public async Task Login_Should_Succeed() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var login = new UserLogin {
+ Email = CreateDummyUser().Email,
+ Password = CreateDummyUser().Password
+ };
+
+ // Act
+ var result = await auth.Login(login);
+
+ // Assert
+ Assert.True(result.IsSuccessful);
+ Assert.Equal(_accessToken.ToString(), result.Data.Value);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Login_With_WrongEmail_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var login = new UserLogin {
+ Email = "wrong@example.com",
+ Password = CreateDummyUser().Password
+ };
+
+ // Act
+ var result = await auth.Login(login);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.NotFound, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Login_With_WrongPassword_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment(false);
+ var login = new UserLogin {
+ Email = CreateDummyUser().Email,
+ Password = CreateDummyUser().Password
+ };
+
+ // Act
+ var result = await auth.Login(login);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Forbidden, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Register_Should_Succeed() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var register = new UserRegister {
+ Email = "new@example.com",
+ Password = "1234567890",
+ Username = "NewUser"
+ };
+
+ // Act
+ var result = await auth.Register(register);
+
+ // Assert
+ Assert.True(result.IsSuccessful);
+ Assert.Equal(_accessToken.ToString(), result.Data.Value);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Register_With_ShortPassword_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var register = new UserRegister {
+ Email = "new@example.com",
+ Password = "12345",
+ Username = "NewUser"
+ };
+
+ // Act
+ var result = await auth.Register(register);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.BadRequest, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Register_With_ExistingUsername_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var register = new UserRegister {
+ Email = "new@example.com",
+ Password = "1234567890",
+ Username = CreateDummyUser().Username
+ };
+
+ // Act
+ var result = await auth.Register(register);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Conflict, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Register_With_ExistingEmail_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ var register = new UserRegister {
+ Email = CreateDummyUser().Email,
+ Password = "1234567890",
+ Username = "NewUser"
+ };
+
+ // Act
+ var result = await auth.Register(register);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Conflict, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Authenticate_Should_Succeed() {
+ // Arrange
+ var token = new Token {
+ Type = Token.RefreshTokenType,
+ Content = _refreshToken,
+ CreatedAt = DateTime.Now,
+ Owner = CreateDummyUser()
+ };
+ var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
+
+ // Act
+ var result = await auth.Authenticate();
+
+ // Assert
+ Assert.True(result.IsSuccessful);
+ Assert.Equal(_accessToken.ToString(), result.Data.Value);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Authenticate_With_NoProvidedToken_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+
+ // Act
+ var result = await auth.Authenticate();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.BadRequest, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Authenticate_With_WrongToken_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment(true, null, _refreshToken.ToString());
+
+ // Act
+ var result = await auth.Authenticate();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.NotFound, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Authenticate_With_WrongTokenType_Should_Fail() {
+ // Arrange
+ var token = new Token {
+ Type = Token.AccessTokenType,
+ Content = _refreshToken,
+ CreatedAt = DateTime.Now,
+ Owner = CreateDummyUser()
+ };
+ var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
+
+ // Act
+ var result = await auth.Authenticate();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Conflict, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Authenticate_With_ExpiredToken_Should_Fail() {
+ // Arrange
+ var token = new Token {
+ Type = Token.RefreshTokenType,
+ Content = _refreshToken,
+ CreatedAt = DateTime.MinValue,
+ Owner = CreateDummyUser()
+ };
+ var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
+
+ // Act
+ var result = await auth.Authenticate();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Forbidden, result.State);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ }
+
+ [Fact]
+ public async Task Logout_Should_Succeed() {
+ // Arrange
+ var (auth, context) = SetupEnvironment(providedTokenCookie: _refreshToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString());
+
+ // Act
+ var result = await auth.Logout();
+
+ // Assert
+ Assert.True(result.IsSuccessful);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Logout_With_NoAccessToken_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment(provideAccessToken: false);
+ context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString());
+
+ // Act
+ var result = await auth.Logout();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Conflict, result.State);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Logout_With_NoRefreshToken_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString());
+
+ // Act
+ var result = await auth.Logout();
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Conflict, result.State);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Delete_Should_Succeed() {
+ // Arrange
+ var (auth, context) = SetupEnvironment();
+ context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString());
+ var validation = new UserPasswordValidation {
+ Password = CreateDummyUser().Password
+ };
+
+ // Act
+ var result = await auth.Delete(validation);
+
+ // Assert
+ Assert.True(result.IsSuccessful);
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+ [Fact]
+ public async Task Delete_With_WrongPassword_Should_Fail() {
+ // Arrange
+ var (auth, context) = SetupEnvironment(false);
+ context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
+ context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString());
+ var validation = new UserPasswordValidation {
+ Password = CreateDummyUser().Password
+ };
+
+ // Act
+ var result = await auth.Delete(validation);
+
+ // Assert
+ Assert.False(result.IsSuccessful);
+ Assert.Equal(HttpStatusCode.Forbidden, result.State);
+ Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
+ Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
+ }
+
+}
\ No newline at end of file
diff --git a/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs
new file mode 100644
index 0000000..4ccbb38
--- /dev/null
+++ b/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs
@@ -0,0 +1,29 @@
+using System.Web;
+using Microsoft.AspNetCore.Http;
+
+namespace HopFrame.Api.Tests.Extensions;
+
+internal static class HttpContextExtensions {
+ /// Extracts the partial cookie value from the header section.
+ ///
+ /// The key for identifying the cookie.
+ /// The value of the cookie.
+ public static string FindCookie(this IHeaderDictionary headers, string key)
+ {
+ string headerKey = $"{key}=";
+ var cookies = headers.Values
+ .SelectMany(h => h)
+ .Where(header => header.StartsWith(headerKey))
+ .Select(header => header.Substring(headerKey.Length).Split(';').First())
+ .ToArray();
+
+ //Note: cookie values in a header are encoded like a uri parameter value.
+ var value = cookies.LastOrDefault();//and the last set value, is the relevant one.
+ if (string.IsNullOrEmpty(value))
+ return null;
+
+ //That's why we should decode that last value, before we return it.
+ var decoded = HttpUtility.UrlDecode(value);
+ return decoded;
+ }
+}
\ No newline at end of file
diff --git a/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj b/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj
new file mode 100644
index 0000000..6c7f59f
--- /dev/null
+++ b/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ disable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+