Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add logout endpoint #21

Merged
merged 5 commits into from
Aug 3, 2023
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
105 changes: 105 additions & 0 deletions src/IIIFAuth2/IIIFAuth2.API.Tests/Integration/AccessServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,97 @@ public async Task SignificantGesture_CreatesSession_AndSetsCookie_AndMarksTokenA
tokenEntity.Entity.Version.Should().NotBe(beforeVersion);
}

[Fact]
public async Task Logout_Returns204_IfNoCookieProvided()
{
// Arrange
const string path = "/access/99/clickthrough/logout";

// Act
var response = await httpClient.GetAsync(path);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

[Fact]
public async Task Logout_Returns204_IfCookieProvided_ButInvalidFormat()
{
// Arrange
const string path = "/access/99/clickthrough/logout";
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Cookie", "dlcs-auth2-99=unexpected-value;");

// Act
var response = await httpClient.SendAsync(request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

[Fact]
public async Task Logout_Returns204_IfCookieProvidedWithId_ButIdNotInDatabase()
{
// Arrange
const string path = "/access/99/clickthrough/logout";
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Cookie", "dlcs-auth2-99=id=123456789;");

// Act
var response = await httpClient.SendAsync(request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

[Fact]
public async Task Logout_Returns204_AndMarksAsExpired_IfCookieValid_AndInDatabase()
{
// Arrange
const string cookieId = nameof(Logout_Returns204_AndMarksAsExpired_IfCookieValid_AndInDatabase);
var sessionUser =
await dbContext.SessionUsers.AddAsync(CreateSessionUser(cookieId, expires: DateTime.UtcNow.AddHours(1)));
await dbContext.SaveChangesAsync();

const string path = "/access/99/clickthrough/logout";
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Cookie", $"dlcs-auth2-99=id={cookieId};");

// Act
var response = await httpClient.SendAsync(request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
await dbContext.Entry(sessionUser.Entity).ReloadAsync();
sessionUser.Entity.LastChecked.Should().HaveValue();
sessionUser.Entity.Expires.Should().BeBefore(DateTime.UtcNow);
}

[Fact]
public async Task Logout_SetsExpiredCookie_IfCookieValid()
{
// Arrange
const string cookieId = nameof(Logout_SetsExpiredCookie_IfCookieValid);
await dbContext.SessionUsers.AddAsync(CreateSessionUser(cookieId));
await dbContext.SaveChangesAsync();

const string path = "/access/99/clickthrough/logout";
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Cookie", $"dlcs-auth2-99=id={cookieId};");

// Act
var response = await httpClient.SendAsync(request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);

response.Headers.Should().ContainKey("Set-Cookie");
var cookie = response.Headers.SingleOrDefault(header => header.Key == "Set-Cookie").Value.First();

var expires = DateTime.Parse(cookie.Split(';').Single(c => c.Trim().StartsWith("expires")).Split('=')[1]);
expires.Should().BeBefore(DateTime.UtcNow);
}

private static async Task ValidateWindowCloseWithError(HttpResponseMessage response)
{
var htmlParser = new HtmlParser();
Expand All @@ -311,4 +402,18 @@ private static RoleProvisionToken CreateToken(string token, bool used, string[]
Id = token, Created = DateTime.UtcNow, Customer = 99, Roles = roles.ToList(), Used = used,
Origin = "http://localhost"
};

private static SessionUser CreateSessionUser(string cookieId, int customer = 99, DateTime? expires = null)
=> new()
{
Id = Guid.NewGuid(),
CookieId = cookieId,
AccessToken = "found-access-token",
Customer = 99,
Created = DateTime.UtcNow,
Roles = new List<string> { "foo" },
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
Origin = "http://localhost/",
LastChecked = null
};
}
20 changes: 20 additions & 0 deletions src/IIIFAuth2/IIIFAuth2.API/Features/Access/AccessController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,24 @@ public async Task<IActionResult> SignificantGesture(
return View("CloseWindow", errorMessage);
});
}

/// <summary>
/// IIIF Authorization logout service
/// https://iiif.io/api/auth/2.0/#logout-service
/// </summary>
[HttpGet]
[Route("{customerId}/{accessServiceName}/logout")]
public async Task<IActionResult> AccessService(
[FromRoute] int customerId,
[FromRoute] string accessServiceName,
CancellationToken cancellationToken)
{
return await HandleRequest(async () =>
{
var logout = new HandleLogoutRequest(customerId, accessServiceName);
await Mediator.Send(logout, cancellationToken);

return NoContent();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using IIIFAuth2.API.Infrastructure.Auth;
using MediatR;

namespace IIIFAuth2.API.Features.Access.Requests;

/// <summary>
/// Log specified user out of session for customer
/// </summary>
public class HandleLogoutRequest : IRequest<bool>
{
public int CustomerId { get; }
public string AccessServiceName { get; }

public HandleLogoutRequest(int customerId, string accessServiceName)
{
CustomerId = customerId;
AccessServiceName = accessServiceName;
}
}

public class HandleLogoutRequestHandler : IRequestHandler<HandleLogoutRequest, bool>
{
private readonly SessionCleaner sessionCleaner;

public HandleLogoutRequestHandler(SessionCleaner sessionCleaner)
{
this.sessionCleaner = sessionCleaner;
}

public async Task<bool> Handle(HandleLogoutRequest request, CancellationToken cancellationToken)
{
var success = await sessionCleaner.LogoutUser(request.CustomerId, cancellationToken);
return success;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace IIIFAuth2.API.Infrastructure.Auth;
/// <remarks>This is based on the original implementation for iiif auth 1.0 in Protagonist</remarks>
public class AuthAspectManager
{
private delegate void DomainCookieHandler(HttpContext httpContext, string cookieId, string domain);
private readonly IHttpContextAccessor httpContextAccessor;
private readonly AuthSettings authSettings;
private const string CookiePrefix = "id=";
Expand Down Expand Up @@ -54,30 +55,32 @@ public string GetCookieValueForId(string cookieId)
? cookieValue
: null;
}

/// <summary>
/// Add cookie to current Response object, using details from specified <see cref="SessionUser"/>
/// </summary>
public void IssueCookie(SessionUser sessionUser)
{
var httpContext = GetContext();
var domains = GetCookieDomainList(httpContext);

var cookieValue = GetCookieValueForId(sessionUser.CookieId);
var cookieId = GetAuthCookieKey(authSettings.CookieNameFormat, sessionUser.Customer);
=> HandleCookieDomain(sessionUser.Customer,
(httpContext, cookieId, domain) =>
{
var cookieValue = GetCookieValueForId(sessionUser.CookieId);
httpContext.Response.Cookies.Append(cookieId, cookieValue,
new CookieOptions
{
Domain = domain,
Expires = DateTimeOffset.UtcNow.AddSeconds(authSettings.SessionTtl),
SameSite = SameSiteMode.None,
Secure = true
});
});

foreach (var domain in domains)
{
httpContext.Response.Cookies.Append(cookieId, cookieValue,
new CookieOptions
{
Domain = domain,
Expires = DateTimeOffset.UtcNow.AddSeconds(authSettings.SessionTtl),
SameSite = SameSiteMode.None,
Secure = true
});
}
}
/// <summary>
/// Remove cookie for customer from current Response object
/// </summary>
public void RemoveCookieFromResponse(int customerId)
=> HandleCookieDomain(customerId,
(httpContext, cookieId, domain) =>
httpContext.Response.Cookies.Delete(cookieId, new CookieOptions { Domain = domain }));

/// <summary>
/// Get the Id of provided access-token from Bearer token header
Expand All @@ -92,6 +95,19 @@ public void IssueCookie(SessionUser sessionUser)
? parsed.Parameter
: null;
}

private void HandleCookieDomain(int customer, DomainCookieHandler domainCookieHandler)
{
var httpContext = GetContext();
var domains = GetCookieDomainList(httpContext);

var cookieId = GetAuthCookieKey(authSettings.CookieNameFormat, customer);

foreach (var domain in domains)
{
domainCookieHandler(httpContext, cookieId, domain);
}
}

private HttpContext GetContext() =>
httpContextAccessor.HttpContext.ThrowIfNull(nameof(httpContextAccessor.HttpContext));
Expand Down
86 changes: 86 additions & 0 deletions src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/SessionCleaner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using IIIFAuth2.API.Data;
using IIIFAuth2.API.Data.Entities;
using IIIFAuth2.API.Infrastructure.Auth.Models;
using LazyCache;

namespace IIIFAuth2.API.Infrastructure.Auth;

/// <summary>
/// Service for handling logging user out and cleaning up session
/// </summary>
public class SessionCleaner : SessionManagerBase
{
private readonly IAppCache appCache;

public SessionCleaner(
AuthServicesContext dbContext,
AuthAspectManager authAspectManager,
IAppCache appCache,
ILogger<SessionManagementService> logger) : base(dbContext, authAspectManager, logger)
{
this.appCache = appCache;
}

public async Task<bool> LogoutUser(int customerId, CancellationToken cancellationToken)
{
var sessionUser = await GetExistingSession(customerId, cancellationToken);
if (sessionUser == null) return false;

Logger.LogDebug("Logging out session {SessionUserId} for {CustomerId}", sessionUser.Id, customerId);

var saveSuccess = await ExpireSessionInDatabase(cancellationToken, sessionUser);

InvalidateCache(sessionUser);

AuthAspectManager.RemoveCookieFromResponse(sessionUser.Customer);
return saveSuccess;
}

private async Task<SessionUser?> GetExistingSession(int customerId, CancellationToken cancellationToken)
{
if (!TryGetCookieId(customerId, out var cookieId, out _)) return null;

var findSessionResponse = await GetSessionUser(
su => su.CookieId == cookieId && su.Customer == customerId,
customerId,
cookieId,
null,
cancellationToken);

return findSessionResponse.IsSuccessWithSession() ? findSessionResponse.SessionUser : null;
}

private async Task<bool> ExpireSessionInDatabase(CancellationToken cancellationToken, SessionUser sessionUser)
{
try
{
sessionUser.LastChecked = DateTime.UtcNow;
sessionUser.Expires = DateTime.UtcNow.AddSeconds(-10);
await DbContext.SaveChangesAsync(cancellationToken);
return true;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving expired SessionUser object {SessionUserId} for {CustomerId}",
sessionUser.Id, sessionUser.Customer);
return false;
}
}

private void InvalidateCache(SessionUser sessionUser)
{
try
{
appCache.Remove(CacheKeys.AuthAspect(sessionUser.CookieId, sessionUser.Origin));
appCache.Remove(CacheKeys.AuthAspect(sessionUser.CookieId));
appCache.Remove(CacheKeys.AuthAspect(sessionUser.AccessToken, sessionUser.Origin));
appCache.Remove(CacheKeys.AuthAspect(sessionUser.AccessToken));
}
catch (Exception ex)
{
Logger.LogError(ex,
"Error invalidating caches for expired SessionUser object {SessionUserId} for {CustomerId}",
sessionUser.Id, sessionUser.Customer);
}
}
}
Loading