Skip to content

Commit

Permalink
[ODS-6418] Enforce token limits on API clients (#1117)
Browse files Browse the repository at this point in the history
  • Loading branch information
mjaksn authored Aug 13, 2024
1 parent 66a9726 commit d1a10d0
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ protected override void Load(ContainerBuilder builder)
new ResolvedParameter(
(p, c) => p.Name == "tokenDurationMinutes",
(p, c) => c.Resolve<ApiSettings>().BearerTokenTimeoutMinutes))
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name == "tokenPerClientLimit",
(p, c) => c.Resolve<ApiSettings>().BearerTokenPerClientLimit))
.SingleInstance();

builder.RegisterType<PackedHashConverter>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
// See the LICENSE and NOTICES files in the project root for more information.

using System;
using System.Data;
using System.Data.Common;
using System.Threading.Tasks;
using Dapper;
using EdFi.Admin.DataAccess.Providers;
using EdFi.Ods.Common.Exceptions;

namespace EdFi.Ods.Api.Security.Authentication;

Expand All @@ -16,13 +18,16 @@ public class EdFiAdminAccessTokenFactory : IAccessTokenFactory
private readonly DbProviderFactory _dbProviderFactory;
private readonly IAdminDatabaseConnectionStringProvider _adminDatabaseConnectionStringProvider;
private readonly int _tokenDurationMinutes;
private readonly int _tokenPerClientLimit;

private const string InsertClientAccessTokenSql = "INSERT INTO dbo.ClientAccessTokens(Id, Expiration, Scope, ApiClient_ApiClientId) VALUES (@Id, @Expiration, @Scope, @ApiClientId);";
private const string AddTokenProcedureName = "dbo.CreateClientAccessToken";
private const string TokenLimitReachedDbMessage = "Token limit reached";

public EdFiAdminAccessTokenFactory(
DbProviderFactory dbProviderFactory,
IAdminDatabaseConnectionStringProvider adminDatabaseConnectionStringProvider,
int tokenDurationMinutes)
int tokenDurationMinutes,
int tokenPerClientLimit)
{
if (tokenDurationMinutes <= 0)
{
Expand All @@ -33,8 +38,9 @@ public EdFiAdminAccessTokenFactory(
_adminDatabaseConnectionStringProvider = adminDatabaseConnectionStringProvider;

_tokenDurationMinutes = tokenDurationMinutes;
_tokenPerClientLimit = tokenPerClientLimit;
}

public async Task<AccessToken> CreateAccessTokenAsync(int apiClientId, string scope = null)
{
await using var connection = _dbProviderFactory.CreateConnection();
Expand All @@ -43,14 +49,22 @@ public async Task<AccessToken> CreateAccessTokenAsync(int apiClientId, string sc

var @params = new
{
Id = Guid.NewGuid(),
Expiration = DateTime.UtcNow.Add(TimeSpan.FromMinutes(_tokenDurationMinutes)),
Scope = scope,
ApiClientId = apiClientId
id = Guid.NewGuid(),
expiration = DateTime.UtcNow.Add(TimeSpan.FromMinutes(_tokenDurationMinutes)),
scope = scope,
apiclientid = apiClientId,
maxtokencount = _tokenPerClientLimit
};

await connection.ExecuteAsync(InsertClientAccessTokenSql, @params);

return new AccessToken(@params.Id, TimeSpan.FromMinutes(_tokenDurationMinutes), scope);
try
{
await connection.ExecuteAsync(AddTokenProcedureName, @params, commandType: CommandType.StoredProcedure);
}
catch (DbException ex) when (ex.Message.Contains(TokenLimitReachedDbMessage))
{
throw new TooManyTokensException(_tokenPerClientLimit);
}

return new AccessToken(@params.id, TimeSpan.FromMinutes(_tokenDurationMinutes), scope);
}
}
2 changes: 2 additions & 0 deletions Application/EdFi.Ods.Common/Configuration/ApiSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public ApiSettings()
}

public int BearerTokenTimeoutMinutes { get; set; } = 60;

public int BearerTokenPerClientLimit { get; set; } = 15;

public int DefaultPageSizeLimit { get; set; } = 500;

Expand Down
46 changes: 46 additions & 0 deletions Application/EdFi.Ods.Common/Exceptions/TooManyTokensException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using System.Collections.Generic;
using Microsoft.AspNetCore.Http;

namespace EdFi.Ods.Common.Exceptions;

public class TooManyTokensException : EdFiProblemDetailsExceptionBase
{
// Fields containing override values for Problem Details
private const string TypePart = "security:authentication:too-many-tokens";
private const string TitleText = "Too Many Tokens";

private const int StatusValue = StatusCodes.Status429TooManyRequests;

private const string DetailText = "The caller has authenticated too many times in too short of a time period.";
private const string ErrorText = "Too many access tokens have been requested (limit is {0}). Access tokens should be reused until they expire.";

/// <summary>
/// Initializes a new instance of the <see cref="TooManyTokensException"/> class using the default text and error messages
/// indicating that the maximum token limit has been reached by the API client.
/// </summary>
public TooManyTokensException(int tokenPerClientLimit)
: base(DetailText, [string.Format(ErrorText, tokenPerClientLimit.ToString())]) { }

// ---------------------------
// Boilerplate for overrides
// ---------------------------
public override string Title { get => TitleText; }

public override int Status { get => StatusValue; }

protected override IEnumerable<string> GetTypeParts()
{
foreach (var part in base.GetTypeParts())
{
yield return part;
}

yield return TypePart;
}
// ---------------------------
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- SPDX-License-Identifier: Apache-2.0
-- Licensed to the Ed-Fi Alliance under one or more agreements.
-- The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
-- See the LICENSE and NOTICES files in the project root for more information.

CREATE OR ALTER PROCEDURE dbo.CreateClientAccessToken(
@Id UNIQUEIDENTIFIER = NULL,
@Expiration DATETIME = NULL,
@Scope NVARCHAR(max) = NULL,
@ApiClientId INT = NULL,
@MaxTokenCount INT = NULL
)
AS
BEGIN
SET NOCOUNT ON

DECLARE @ActiveTokenCount INT

IF @MaxTokenCount < 1
SET @ActiveTokenCount = 0
ELSE
BEGIN
SET @ActiveTokenCount = (SELECT COUNT(1)
FROM dbo.ClientAccessTokens actoken
WHERE ApiClient_ApiClientId = @ApiClientId
AND actoken.Expiration > GETUTCDATE())
END

IF (@MaxTokenCount < 1) OR (@ActiveTokenCount < @MaxTokenCount)
BEGIN
INSERT INTO dbo.ClientAccessTokens(Id, Expiration, Scope, ApiClient_ApiClientId)
VALUES (@Id, @Expiration, @Scope, @ApiClientId)
END
ELSE
THROW 50000, 'Token limit reached', 1;
END
GO
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- SPDX-License-Identifier: Apache-2.0
-- Licensed to the Ed-Fi Alliance under one or more agreements.
-- The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
-- See the LICENSE and NOTICES files in the project root for more information.

CREATE OR REPLACE PROCEDURE dbo.CreateClientAccessToken(
id uuid,
expiration timestamp without time zone,
scope text,
apiclientid integer,
maxtokencount integer)
AS
$BODY$
DECLARE
active_token_count integer;
BEGIN

IF maxtokencount < 1 THEN
active_token_count := 0;
ELSE
active_token_count := (SELECT COUNT(1)
FROM dbo.clientaccesstokens actoken
WHERE apiclient_apiclientid = ApiClientId
AND actoken.expiration > current_timestamp at time zone 'utc');
END IF;

IF (maxtokencount < 1) OR (active_token_count < maxtokencount) THEN
INSERT INTO dbo.ClientAccessTokens(id, expiration, scope, apiclient_apiclientid)
VALUES (CreateClientAccessToken.id, CreateClientAccessToken.expiration, CreateClientAccessToken.scope,
apiclientid);
ELSE
RAISE EXCEPTION USING MESSAGE = 'Token limit reached';
END IF;

END
$BODY$
LANGUAGE plpgsql;

0 comments on commit d1a10d0

Please sign in to comment.