Skip to content

Commit 9f3a741

Browse files
authored
feat: Logout functionality (#14)
* feat: Fix hash validation * fix: Adjust k6 scenario with correct user creds * feat: Logout endpoint
1 parent 4782e50 commit 9f3a741

File tree

9 files changed

+215
-12
lines changed

9 files changed

+215
-12
lines changed

src/UserAPI.Application/Common/Abstraction/Repository/ISessionsRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace UserAPI.Application.Common.Abstraction.Repository
55
{
66
public interface ISessionsRepository
77
{
8+
Task<bool> SetEndedAsync(string id);
89
Task<SessionEntity> GetAsync(string id);
910
Task CreateAsync(SessionEntity entity);
1011
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using FluentValidation;
5+
using MediatR;
6+
using UserAPI.Application.Common.Model.Result;
7+
using UserAPI.Domain.ValueObject;
8+
using OneOf;
9+
using Serilog;
10+
using UserAPI.Application.Common.Abstraction.Repository;
11+
using UserAPI.Application.Common.Extension;
12+
using UserAPI.Application.Common.Model.Constant;
13+
using UserAPI.Application.Handler.Command;
14+
15+
namespace UserAPI.Application.Handler.Query
16+
{
17+
public static class LogoutUser
18+
{
19+
public sealed class Request : IRequest<OneOf<Response, ValidationFail, InternalError>>
20+
{
21+
public JwtClaims JwtClaims { get; }
22+
23+
public Request(JwtClaims jwtClaims)
24+
{
25+
JwtClaims = jwtClaims;
26+
}
27+
}
28+
29+
public sealed class Response
30+
{
31+
public Response()
32+
{
33+
}
34+
}
35+
36+
public sealed class RequestValidator : AbstractValidator<Request>
37+
{
38+
public RequestValidator()
39+
{
40+
RuleFor(i => i.JwtClaims.UserId)
41+
.NotNull()
42+
.NotEmpty();
43+
44+
RuleFor(i => i.JwtClaims.IssuedAt)
45+
.NotNull()
46+
.Must(i => i.IsPassed())
47+
.WithMessage(ErrorMessage.InvalidJWT);
48+
49+
RuleFor(i => i.JwtClaims.ExpiresAt)
50+
.NotNull()
51+
.Must(i => !i.IsPassed())
52+
.WithMessage(ErrorMessage.InvalidJWT);
53+
}
54+
}
55+
56+
public sealed class Handler : IRequestHandler<Request,
57+
OneOf<Response, ValidationFail, InternalError>>
58+
{
59+
private static readonly ILogger Logger = Log.ForContext(typeof(LogoutUser));
60+
61+
private readonly IValidator<Request> _requestValidator;
62+
private readonly ISessionsRepository _sessionsRepository;
63+
64+
public Handler(ISessionsRepository sessionsRepository,
65+
IValidator<Request> requestValidator)
66+
{
67+
_sessionsRepository = sessionsRepository;
68+
_requestValidator = requestValidator;
69+
}
70+
71+
public async Task<OneOf<Response, ValidationFail, InternalError>> Handle(Request request,
72+
CancellationToken cancellationToken)
73+
{
74+
try
75+
{
76+
var requestValidationResult = await _requestValidator.ValidateAsync(request, cancellationToken);
77+
if (!requestValidationResult.IsValid)
78+
return ValidationFail.FromValidationResult(requestValidationResult);
79+
80+
var session = await _sessionsRepository.GetAsync(request.JwtClaims.SessionId);
81+
if (session == null || session.UserId != request.JwtClaims.UserId)
82+
{
83+
return ValidationFail.FromMessage(ErrorMessage.InvalidSession);
84+
}
85+
86+
if (session.EndTime.HasValue && session.EndTime.Value.IsPassed())
87+
{
88+
return ValidationFail.FromMessage(ErrorMessage.InvalidSession);
89+
}
90+
91+
var isUpdated = await _sessionsRepository.SetEndedAsync(session.Id);
92+
93+
if (!isUpdated)
94+
{
95+
Logger.Error("Session wasn't able to update for userId: {userId}, sessionId: {sessionId}",
96+
request.JwtClaims.UserId, request.JwtClaims.SessionId);
97+
return InternalError.Default;
98+
}
99+
100+
return new Response();
101+
}
102+
catch (Exception e)
103+
{
104+
Logger.Error(e, "Exception during logout for userId: {userId}, sessionId: {sessionId}",
105+
request.JwtClaims.UserId, request.JwtClaims.SessionId);
106+
return InternalError.Default;
107+
}
108+
}
109+
}
110+
}
111+
}

src/UserAPI.Application/UserAPI.Application.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,4 @@
1515
<PackageReference Include="Serilog" Version="2.10.0" />
1616
</ItemGroup>
1717

18-
<ItemGroup>
19-
<Folder Include="Handler\Query" />
20-
</ItemGroup>
21-
2218
</Project>

src/UserAPI.Host/Controllers/UsersController.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
using System.Linq;
2-
using System.Threading.Tasks;
1+
using System.Threading.Tasks;
32
using MediatR;
43
using Microsoft.AspNetCore.Authorization;
54
using Microsoft.AspNetCore.Mvc;
65
using UserAPI.Application.Handler.Command;
6+
using UserAPI.Application.Handler.Query;
77
using UserAPI.Contracts.Request;
88
using UserAPI.Contracts.Response;
99
using UserAPI.Host.Extensions;
@@ -53,5 +53,15 @@ public async Task<IActionResult> RefreshToken([FromBody] RefreshJwtRequest reque
5353
return domainResponse.Match(response =>
5454
Ok(new RefreshJwtResponse(response.Jwt, response.RefreshToken)), BadRequest, InternalError);
5555
}
56+
57+
[Authorize]
58+
[HttpPost("logout")]
59+
public async Task<IActionResult> Logout()
60+
{
61+
var domainRequest = new LogoutUser.Request(HttpContext.User.Claims.ToClaimsObject());
62+
63+
var domainResponse = await _mediator.Send(domainRequest);
64+
return domainResponse.Match(Ok, BadRequest, InternalError);
65+
}
5666
}
5767
}

src/UserAPI.Infrastructure/Repository/SessionRepository.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Threading.Tasks;
23
using MongoDB.Driver;
34
using UserAPI.Application.Common.Abstraction.Repository;
@@ -14,6 +15,19 @@ public SessionRepository(IMongoDatabase database)
1415
_database = database;
1516
}
1617

18+
public async Task<bool> SetEndedAsync(string id)
19+
{
20+
var collection = _database.GetCollection<SessionEntity>("sessions");
21+
var filterBuilder = Builders<SessionEntity>.Filter;
22+
var filter = filterBuilder.Eq(i => i.Id, id);
23+
24+
var updateBuilder = Builders<SessionEntity>.Update;
25+
var update = updateBuilder.Set(i => i.EndTime, DateTime.UtcNow);
26+
27+
var result = await collection.UpdateOneAsync(filter, update);
28+
return result.IsAcknowledged;
29+
}
30+
1731
public async Task<SessionEntity> GetAsync(string id)
1832
{
1933
var collection = _database.GetCollection<SessionEntity>("sessions");

src/UserAPI.Infrastructure/Service/Pbkdf2PasswordService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public PasswordHash GenerateHash(string password, string salt = null)
2929
public bool ValidateHash(string hash, string password, string salt)
3030
{
3131
var pwdHash = GenerateHash(password, salt);
32-
return string.Equals(pwdHash.Hash, hash, StringComparison.OrdinalIgnoreCase);
32+
return string.Equals(pwdHash.Hash, hash);
3333
}
3434

3535
private byte[] GenerateSalt()

tests/UserAPI.E2E/Scenario/Base/Scenario.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ public Scenario()
4141
};
4242
}
4343

44+
protected Task<(HttpStatusCode code, string strResponse)> SendAsJson(string path, string jwt = null)
45+
{
46+
if (!string.IsNullOrEmpty(jwt))
47+
{
48+
return SendAsJsonInternal(path, string.Empty, AuthorizedHttpClient(jwt));
49+
}
50+
51+
return SendAsJsonInternal(path, string.Empty, HttpClient);
52+
}
53+
4454
protected Task<(HttpStatusCode code, string strResponse, TResponse response)> SendAsJson<TRequest,
4555
TResponse>(string path, TRequest request, string jwt = null)
4656
{
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Net;
2+
using System.Threading.Tasks;
3+
using NUnit.Framework;
4+
using UserAPI.Contracts.Request;
5+
using UserAPI.Contracts.Response;
6+
using UserAPI.Host.IntegrationTests.Common.Builder;
7+
8+
namespace UserAPI.Host.IntegrationTests.Scenario
9+
{
10+
public class LogoutScenario : Base.Scenario
11+
{
12+
[Test]
13+
public async Task Should_SuccessfullyLogoutUser_When_NotExpiredJwtWithSessionPassed()
14+
{
15+
var authenticationRequest = new AuthenticationRequestBuilder()
16+
.WithLogin(Config.TestData.ValidUser.Username)
17+
.WithPassword(Config.TestData.ValidUser.Password)
18+
.Build();
19+
20+
var authResponse =
21+
await SendAsJson<AuthenticateUserRequest, AuthenticateUserResponse>("users/authenticate",
22+
authenticationRequest);
23+
24+
var logoutResponse = await SendAsJson("users/logout", authResponse.response.Jwt);
25+
26+
Assert.AreEqual(HttpStatusCode.OK, logoutResponse.code);
27+
}
28+
29+
[Test]
30+
public async Task Should_FailLogoutUser_When_ExpiredJwtPassed()
31+
{
32+
var expiredJwt = Config.TestData.ValidUser.ExpiredJwt;
33+
34+
var logoutResponse = await SendAsJson("users/logout", expiredJwt);
35+
36+
Assert.AreEqual(HttpStatusCode.BadRequest, logoutResponse.code);
37+
Assert.IsTrue(logoutResponse.strResponse.Contains("Invalid JWT"));
38+
}
39+
40+
[Test]
41+
public async Task Should_FailLogoutUser_When_EndedSessionInJwtPassed()
42+
{
43+
var authenticationRequest = new AuthenticationRequestBuilder()
44+
.WithLogin(Config.TestData.ValidUser.Username)
45+
.WithPassword(Config.TestData.ValidUser.Password)
46+
.Build();
47+
48+
var authResponse =
49+
await SendAsJson<AuthenticateUserRequest, AuthenticateUserResponse>("users/authenticate",
50+
authenticationRequest);
51+
52+
// Send first logout request to end the session
53+
await SendAsJson("users/logout", authResponse.response.Jwt);
54+
55+
var logoutResponse = await SendAsJson("users/logout", authResponse.response.Jwt);
56+
57+
Assert.AreEqual(HttpStatusCode.BadRequest, logoutResponse.code);
58+
Assert.IsTrue(logoutResponse.strResponse.Contains("Invalid session"));
59+
}
60+
}
61+
}

tests/k6/authenticate-load.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ import { check, sleep } from 'k6';
33

44
export const options = {
55
stages: [
6-
{ duration: '10s', target: 20 },
7-
{ duration: '5m', target: 20 },
6+
{ duration: '10s', target: 10 },
7+
{ duration: '5m', target: 10 },
88
{ duration: '10s', target: 0 },
99
],
1010
};
1111

1212
export default function () {
1313
const payload = JSON.stringify({
14-
login: 'kovetskiy',
15-
password: '1234567',
14+
login: 'testuser1',
15+
password: '123456aF1!',
1616
});
1717

1818
const params = {
@@ -22,6 +22,6 @@ export default function () {
2222
};
2323

2424

25-
const res = http.post('http://127.0.0.1:5000/users/authenticate', payload, params);
25+
const res = http.post('http://127.0.0.1:50000/users/authenticate', payload, params);
2626
check(res, { 'status was 200': (r) => r.status == 200 });
2727
}

0 commit comments

Comments
 (0)