Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions backend/Platform.Api/Controllers/Identity/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Platform.Api.Services.Identity;

namespace Platform.Api.Controllers.Identity;

public sealed record LoginRequest(string? Email, string? Password);

[ApiController]
[Route("api/auth")]
public class AuthController(ILoginService login, ILogoutService logout, IIdentityContext identity) : ControllerBase
{
private readonly IIdentityContext _identity = identity;
private readonly ILoginService _login = login;
private readonly ILogoutService _logout = logout;

[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest body, CancellationToken ct)
{
var result = await _login.ExecuteAsync(body.Email, body.Password, HttpContext.TraceIdentifier, ct);
Response.Headers.CacheControl = "no-store";

switch (result.Outcome)
{
case LoginOutcome.Success:
// The token goes only into the cookie, never into the body.
Response.Cookies.Append(SessionCookie.Name, result.Session!.Token, SessionCookie.Options(result.Session.ExpiresAt));
return Ok(new { userId = result.UserId });
case LoginOutcome.InvalidRequest:
return BadRequest(new { code = "invalid_request" });
case LoginOutcome.Unavailable:
return StatusCode(503, new { code = "login_unavailable" });
default:
return Unauthorized(new { code = "invalid_credentials" });
}
}

[AllowAnonymous]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
// Revoke first: if it fails, the cookie stays and the client can try again.
// No request token: a closed tab or a broken connection must not leave the session valid.
await _logout.ExecuteAsync(Request.Cookies[SessionCookie.Name], HttpContext.TraceIdentifier, CancellationToken.None);

Response.Headers.CacheControl = "no-store";
Response.Cookies.Delete(SessionCookie.Name, SessionCookie.Options(DateTimeOffset.UnixEpoch));
return NoContent();
}

[HttpGet("me")]
public IActionResult Me()
{
var caller = _identity.Current;

if (caller == null || !caller.IsAuthenticated)
return Unauthorized();

Response.Headers.CacheControl = "no-store";
return Ok(new { userId = caller.UserId, level = caller.Level.ToString() });
}
}
34 changes: 0 additions & 34 deletions backend/Platform.Api/Controllers/Identity/LoginController.cs

This file was deleted.

24 changes: 0 additions & 24 deletions backend/Platform.Api/Controllers/Identity/LogoutController.cs

This file was deleted.

28 changes: 0 additions & 28 deletions backend/Platform.Api/Controllers/Identity/MeController.cs

This file was deleted.

2 changes: 1 addition & 1 deletion backend/Platform.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
builder.Services.AddSingleton<IAccessDecision, AdminOnlyAccessDecision>();
builder.Services.AddScoped<ChangeAccountStatus>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<LogoutAccount>();
builder.Services.AddScoped<LogoutService>();
builder.Services.AddScoped<ISessionCreator, StoreSessionCreator>();

builder.Services.AddScoped<IIdentityContext, SessionIdentityContext>();
Expand Down
7 changes: 7 additions & 0 deletions backend/Platform.Api/Services/Identity/ILoginService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Platform.Api.Services.Identity;

public interface ILoginService
{
public Task<LoginResult> ExecuteAsync(string? email, string? password, string correlationId,
CancellationToken ct = default);
}
6 changes: 6 additions & 0 deletions backend/Platform.Api/Services/Identity/ILogoutService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Platform.Api.Services.Identity;

public interface ILogoutService
{
public Task ExecuteAsync(string? rawToken, string correlationId, CancellationToken ct = default);
}
2 changes: 1 addition & 1 deletion backend/Platform.Api/Services/Identity/LoginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public sealed class LoginService(
ICredentialVerifier credentials,
ISessionCreator sessions,
IAuditWriter audit,
TimeProvider clock)
TimeProvider clock) : ILoginService
{
private const string AuditAction = "auth.login";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Platform.Api.Services.Identity;
/// sessions and only touches this one session. Without a session to revoke it does nothing, so the caller
/// answers the same in every case and learns nothing about the session state.
/// </summary>
public sealed class LogoutAccount(ISessionStore sessions, IAuditWriter audit, TimeProvider clock)
public sealed class LogoutService(ISessionStore sessions, IAuditWriter audit, TimeProvider clock) : ILogoutService
{
private const string AuditAction = "auth.logout";

Expand Down
153 changes: 153 additions & 0 deletions backend/Platform.Tests/Identity/Controllers/АuthControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Platform.Api.Controllers.Identity;
using Platform.Api.Domain.Authentication;
using Platform.Api.Services.Identity;
using Moq;

namespace Platform.Tests.Identity.Controllers;

[TestFixture]
[Category("US-06")]
public class AuthControllerTests
{
private Mock<ILoginService> _login = null!;
private Mock<ILogoutService> _logout = null!;
private Mock<IIdentityContext> _identity = null!;
private AuthController _controller = null!;

[SetUp]
public void SetUp()
{
_login = new Mock<ILoginService>();
_logout = new Mock<ILogoutService>();
_identity = new Mock<IIdentityContext>();
_controller = new AuthController(_login.Object, _logout.Object, _identity.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}

[Test]
public void NoCaller_Returns401()
{
CallerIs(null);

var result = _controller.Me();

Assert.That(result, Is.InstanceOf<UnauthorizedResult>());
}

[Test]
public void MfaPendingCaller_Returns401()
{
CallerIs(new CallerIdentity(false, Guid.NewGuid(), AuthenticationLevel.MfaPending, Guid.NewGuid()));

var result = _controller.Me();

Assert.That(result, Is.InstanceOf<UnauthorizedResult>());
}

[Test]
public void ActiveCaller_ReturnsOnlyUserIdAndLevel()
{
var userId = Guid.NewGuid();
CallerIs(new CallerIdentity(true, userId, AuthenticationLevel.Active, Guid.NewGuid()));

var result = _controller.Me() as OkObjectResult;

Assert.That(result, Is.Not.Null);
var body = result!.Value!;
var names = body.GetType().GetProperties().Select(p => p.Name).ToArray();
Assert.That(names, Is.EquivalentTo(["userId", "level"]));
Assert.That(body.GetType().GetProperty("userId")!.GetValue(body), Is.EqualTo(userId));
Assert.That(body.GetType().GetProperty("level")!.GetValue(body), Is.EqualTo("Active"));
Assert.That(_controller.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store"));
}

private void CallerIs(CallerIdentity? caller) =>
_identity.Setup(i => i.Current).Returns(caller);

[Test]
public async Task Logout_RevokesCookieSession_AndClearsTheCookieWithItsAttributes()
{
const string token = "opaque-session-token-value";
var http = HttpContextWithCookie(token);
_controller.ControllerContext.HttpContext = http;

var response = await _controller.Logout();

Assert.That(response, Is.InstanceOf<NoContentResult>());
_logout.Verify(
service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None),
Times.Once);
Assert.That(http.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store"));
AssertCookieCleared(http);
}

[Test]
public async Task Logout_ClientDisconnect_DoesNotCancelTheRevocation()
{
const string token = "opaque-session-token-value";
var http = HttpContextWithCookie(token);
using var aborted = new CancellationTokenSource();
aborted.Cancel();
http.RequestAborted = aborted.Token;
_controller.ControllerContext.HttpContext = http;

await _controller.Logout();

_logout.Verify(
service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None),
Times.Once);
}

[TestCase(null)]
[TestCase("unknown-token")]
public async Task Logout_WithoutValidSession_AnswersTheSame(string? token)
{
var http = HttpContextWithCookie(token);
_controller.ControllerContext.HttpContext = http;

var response = await _controller.Logout();

Assert.That(response, Is.InstanceOf<NoContentResult>());
_logout.Verify(
service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None),
Times.Once);
AssertCookieCleared(http);
}

[Test]
public void Logout_WhenRevokingFails_KeepsTheCookie()
{
const string token = "opaque-session-token-value";
var http = HttpContextWithCookie(token);
_controller.ControllerContext.HttpContext = http;
_logout
.Setup(service => service.ExecuteAsync(It.IsAny<string?>(), It.IsAny<string>(), CancellationToken.None))
.ThrowsAsync(new InvalidOperationException("database is down"));

Assert.ThrowsAsync<InvalidOperationException>(() => _controller.Logout());

Assert.That(http.Response.Headers.ContainsKey("Set-Cookie"), Is.False);
}

private static DefaultHttpContext HttpContextWithCookie(string? token)
{
var http = new DefaultHttpContext();
if (token is not null)
http.Request.Headers.Cookie = $"{SessionCookie.Name}={token}";

return http;
}

private static void AssertCookieCleared(DefaultHttpContext http)
{
var setCookie = http.Response.Headers.SetCookie.ToString().ToLowerInvariant();
Assert.That(setCookie, Does.StartWith(SessionCookie.Name.ToLowerInvariant() + "=;"));
Assert.That(setCookie, Does.Contain("expires=thu, 01 jan 1970"));
Assert.That(setCookie,
Does.Contain("path=/").And.Contain("httponly").And.Contain("secure").And.Contain("samesite=strict"));
}
}
Loading
Loading