ADDED JWT Checks for: NotBefore, ExpirationTime, IssuedAt

Updated JWT sample
Added more unit tests
This commit is contained in:
danieleteti 2016-05-21 21:56:12 +02:00
parent 214d378cdc
commit e1225fdcbe
5 changed files with 266 additions and 21 deletions

View File

@ -1,8 +1,8 @@
object Form7: TForm7
Left = 0
Top = 0
Caption = 'Form7'
ClientHeight = 231
Caption = 'JWT Sample'
ClientHeight = 160
ClientWidth = 505
Color = clBtnFace
Font.Charset = DEFAULT_CHARSET

View File

@ -44,6 +44,8 @@ begin
finally
lClt.Free;
end;
ShowMessage
('In the next 15 seconds you can request protected resources. After your token will expires!');
end;
procedure TForm7.Button2Click(Sender: TObject);
@ -55,7 +57,8 @@ begin
try
lClt.Header('Authentication', 'bearer ' + FToken);
lResp := lClt.doGET('/', []);
ShowMessage(lResp.BodyAsJSONValue.Value);
ShowMessage(lResp.ResponseText + sLineBreak +
lResp.BodyAsJSONValue.Value);
finally
lClt.Free;
end;

View File

@ -46,8 +46,9 @@ procedure TMyController.OnBeforeAction(Context: TWebContext; const AActionName:
var Handled: Boolean);
var
lJWT: TJWT;
lAuthHeader: string;
lToken: string;
lAuthHeader: String;
lToken: String;
lError: String;
begin
{ Executed before each action
if handled is true (or an exception is raised) the actual
@ -59,8 +60,9 @@ begin
lJWT := TJWT.Create('mysecret');
try
lJWT.Claims.Issuer := 'dmvcframework app';
lJWT.Claims.ExpirationTime := Now + OneHour;
lJWT.Claims.ExpirationTime := Now + OneSecond * 15;
lJWT.Claims.NotBefore := Now - OneMinute * 5;
lJWT.Claims.IssuedAt := Now - OneMinute * 5;
lJWT.CustomClaims['username'] := 'dteti';
Render(lJWT.GetToken);
Exit;
@ -69,8 +71,12 @@ begin
end;
end;
// JWT checking
lJWT := TJWT.Create('mysecret');
try
// in this demo, no tolerance... so no LEEWAY time
lJWT.LeewaySeconds := 0;
lAuthHeader := Context.Request.Headers['Authentication'];
if lAuthHeader.IsEmpty then
begin
@ -84,7 +90,7 @@ begin
lToken := lAuthHeader.Remove(0, 'bearer'.Length).Trim;
end;
if not lJWT.IsValidToken(lToken) then
if not lJWT.IsValidToken(lToken, lError) then
begin
Render(http_status.Unauthorized, 'Invalid Token, Authentication Required');
Handled := True;

View File

@ -3,9 +3,13 @@ unit MVCFramework.JWT;
interface
uses
System.Generics.Collections;
System.Generics.Collections, System.JSON;
type
{$SCOPEDENUMS ON}
TJWTCheckableClaim = (ExpirationTime, NotBefore, IssuedAt);
TJWTCheckableClaims = set of TJWTCheckableClaim;
TJWTRegisteredClaimNames = class sealed
public
const
@ -152,22 +156,31 @@ type
FRegisteredClaims: TJWTRegisteredClaims;
FCustomClaims: TJWTCustomClaims;
FHMACAlgorithm: String;
FLeewaySeconds: Int64;
FRegClaimsToChecks: TJWTCheckableClaims;
procedure SetHMACAlgorithm(const Value: String);
procedure SetLeewaySeconds(const Value: Int64);
procedure SetChecks(const Value: TJWTCheckableClaims);
function CheckExpirationTime(Payload: TJSONObject; out Error: String): Boolean;
function CheckNotBefore(Payload: TJSONObject; out Error: String): Boolean;
function CheckIssuedAt(Payload: TJSONObject; out Error: String): Boolean;
public
constructor Create(const SecretKey: String); virtual;
destructor Destroy; override;
function GetToken: String;
function IsValidToken(const Token: String): Boolean;
function IsValidToken(const Token: String; out Error: String): Boolean;
procedure LoadToken(const Token: String);
property Claims: TJWTRegisteredClaims read FRegisteredClaims;
property CustomClaims: TJWTCustomClaims read FCustomClaims;
property HMACAlgorithm: String read FHMACAlgorithm write SetHMACAlgorithm;
property LeewaySeconds: Int64 read FLeewaySeconds write SetLeewaySeconds;
property RegClaimsToChecks: TJWTCheckableClaims read FRegClaimsToChecks write SetChecks;
end;
implementation
uses
System.SysUtils, System.JSON, MVCFramework.Commons, MVCFramework.HMAC, System.DateUtils;
System.SysUtils, MVCFramework.Commons, MVCFramework.HMAC, System.DateUtils;
{ TJWTRegisteredClaims }
@ -293,6 +306,94 @@ end;
{ TJWT }
function TJWT.CheckExpirationTime(Payload: TJSONObject;
out Error: String): Boolean;
var
lJValue: TJSONValue;
lIntValue: Int64;
lValue: string;
begin
lJValue := Payload.GetValue(TJWTRegisteredClaimNames.ExpirationTime);
if not Assigned(lJValue) then
begin
Error := TJWTRegisteredClaimNames.ExpirationTime + ' not set';
Exit(false);
end;
lValue := lJValue.Value;
if not TryStrToInt64(lValue, lIntValue) then
begin
Error := TJWTRegisteredClaimNames.ExpirationTime + ' is not an integer';
Exit(false);
end;
if UnixToDateTime(lIntValue) <= Now - FLeewaySeconds * OneSecond then
begin
Error := 'Token expired';
Exit(false);
end;
Result := True;
end;
function TJWT.CheckIssuedAt(Payload: TJSONObject; out Error: String): Boolean;
var
lJValue: TJSONValue;
lIntValue: Int64;
lValue: string;
begin
lJValue := Payload.GetValue(TJWTRegisteredClaimNames.IssuedAt);
if not Assigned(lJValue) then
begin
Error := TJWTRegisteredClaimNames.IssuedAt + ' not set';
Exit(false);
end;
lValue := lJValue.Value;
if not TryStrToInt64(lValue, lIntValue) then
begin
Error := TJWTRegisteredClaimNames.IssuedAt + ' is not an integer';
Exit(false);
end;
if UnixToDateTime(lIntValue) >= Now + FLeewaySeconds * OneSecond then
begin
Error := 'Token is issued in the future';
Exit(false);
end;
Result := True;
end;
function TJWT.CheckNotBefore(Payload: TJSONObject; out Error: String): Boolean;
var
lJValue: TJSONValue;
lIntValue: Int64;
lValue: string;
begin
lJValue := Payload.GetValue(TJWTRegisteredClaimNames.NotBefore);
if not Assigned(lJValue) then
begin
Error := TJWTRegisteredClaimNames.NotBefore + ' not set';
Exit(false);
end;
lValue := lJValue.Value;
if not TryStrToInt64(lValue, lIntValue) then
begin
Error := TJWTRegisteredClaimNames.NotBefore + ' is not an integer';
Exit(false);
end;
if UnixToDateTime(lIntValue) >= Now + FLeewaySeconds * OneSecond then
begin
Error := 'Token still not valid';
Exit(false);
end;
Result := True;
end;
constructor TJWT.Create(const SecretKey: String);
begin
inherited Create;
@ -300,6 +401,9 @@ begin
FRegisteredClaims := TJWTRegisteredClaims.Create;
FCustomClaims := TJWTCustomClaims.Create;
FHMACAlgorithm := 'HS256';
FLeewaySeconds := 300; // 5 minutes of leeway
FRegClaimsToChecks := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt];
end;
destructor TJWT.Destroy;
@ -325,7 +429,15 @@ begin
for lRegClaimName in TJWTRegisteredClaimNames.Names do
begin
if FRegisteredClaims.Contains(lRegClaimName) then
lPayload.AddPair(lRegClaimName, FRegisteredClaims[lRegClaimName]);
begin
if (lRegClaimName = TJWTRegisteredClaimNames.ExpirationTime) or
(lRegClaimName = TJWTRegisteredClaimNames.NotBefore) or
(lRegClaimName = TJWTRegisteredClaimNames.IssuedAt) then
lPayload.AddPair(lRegClaimName,
TJSONNumber.Create(StrToInt64(FRegisteredClaims[lRegClaimName])))
else
lPayload.AddPair(lRegClaimName, FRegisteredClaims[lRegClaimName]);
end;
end;
for lCustomClaimName in FCustomClaims.Keys do
@ -347,35 +459,61 @@ begin
end;
end;
function TJWT.IsValidToken(const Token: String): Boolean;
function TJWT.IsValidToken(const Token: String; out Error: String): Boolean;
var
lPieces: TArray<String>;
lJHeader: TJSONObject;
lJAlg: TJSONString;
lAlgName: string;
lJPayload: TJSONObject;
lError: String;
begin
lPieces := Token.Split(['.']);
if Length(lPieces) <> 3 then
Exit(False);
Exit(false);
lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject;
try
if not Assigned(lJHeader) then
Exit(False);
Exit(false);
lJPayload := TJSONObject.ParseJSONValue(B64Decode(lPieces[1])) as TJSONObject;
try
if not Assigned(lJPayload) then
Exit(False);
Exit(false);
if not lJHeader.TryGetValue<TJSONString>('alg', lJAlg) then
Exit(False);
Exit(false);
lAlgName := lJAlg.Value;
Result := Token = lPieces[0] + '.' + lPieces[1] + '.' +
B64Encode(
HMAC(lAlgName, lPieces[0] + '.' + lPieces[1], FSecretKey)
);
// if the token is correctly signed and has not been tampered,
// let's check it's validity usinf nbf, exp, iat as configured in
// the RegClaimsToCheck property
if Result then
begin
if TJWTCheckableClaim.ExpirationTime in RegClaimsToChecks then
begin
if not CheckExpirationTime(lJPayload, lError) then
Exit(false);
end;
if TJWTCheckableClaim.NotBefore in RegClaimsToChecks then
begin
if not CheckNotBefore(lJPayload, lError) then
Exit(false);
end;
if TJWTCheckableClaim.IssuedAt in RegClaimsToChecks then
begin
if not CheckIssuedAt(lJPayload, lError) then
Exit(false);
end;
end;
finally
lJPayload.Free;
end;
@ -395,9 +533,10 @@ var
j: Integer;
lIsRegistered: Boolean;
lValue: string;
lError: String;
begin
if not IsValidToken(Token) then
raise EMVCJWTException.Create('Invalid token');
if not IsValidToken(Token, lError) then
raise EMVCJWTException.Create('Invalid token: ' + lError);
lPieces := Token.Split(['.']);
lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject;
@ -413,7 +552,7 @@ begin
FCustomClaims.FClaims.Clear;
for i := 0 to lJPayload.Count - 1 do
begin
lIsRegistered := False;
lIsRegistered := false;
lJPair := lJPayload.Pairs[i];
lName := lJPair.JsonString.Value;
lValue := lJPair.JsonValue.Value;
@ -440,9 +579,19 @@ begin
end;
end;
procedure TJWT.SetChecks(const Value: TJWTCheckableClaims);
begin
FRegClaimsToChecks := Value;
end;
procedure TJWT.SetHMACAlgorithm(const Value: String);
begin
FHMACAlgorithm := Value;
end;
procedure TJWT.SetLeewaySeconds(const Value: Int64);
begin
FLeewaySeconds := Value;
end;
end.

View File

@ -71,6 +71,10 @@ type
procedure TestStorage;
procedure TestCreateAndValidateToken;
procedure TestLoadToken;
procedure TestNotBefore;
procedure TestExpirationTime;
procedure TestIssuedAt;
procedure TestDefaults;
end;
implementation
@ -1078,15 +1082,52 @@ end;
procedure TTestJWT.TestCreateAndValidateToken;
var
lToken: string;
lError: string;
begin
FJWT.Claims.Issuer := 'bit Time Professionals';
FJWT.Claims.Subject := 'DelphiMVCFramework';
FJWT.Claims.JWT_ID := TGUID.NewGuid.ToString;
FJWT.CustomClaims['username'] := 'dteti';
FJWT.CustomClaims['userrole'] := 'admin';
FJWT.Claims.ExpirationTime := Tomorrow;
FJWT.Claims.IssuedAt := Yesterday;
FJWT.Claims.NotBefore := Yesterday;
lToken := FJWT.GetToken;
// TFile.WriteAllText('jwt_token.dat', lToken);
CheckTrue(FJWT.IsValidToken(lToken), 'Generated token is not valid');
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Generated token is not valid');
end;
procedure TTestJWT.TestDefaults;
begin
CheckEquals('HS256', FJWT.HMACAlgorithm, 'Default algorithm should be HS256');
CheckEquals(300, FJWT.LeewaySeconds, 'Default leeway should be 5 minutes');
if FJWT.RegClaimsToChecks * [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt] <> [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt] then
Fail('Default RegClaimsToCheck not correct');
end;
procedure TTestJWT.TestExpirationTime;
var
lToken: string;
lError: string;
begin
FJWT.RegClaimsToChecks := [TJWTCheckableClaim.ExpirationTime];
FJWT.Claims.ExpirationTime := Tomorrow;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered expired');
FJWT.Claims.ExpirationTime := Yesterday;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Expired token is considered valid');
FJWT.Claims.ExpirationTime := Now;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered expired');
FJWT.Claims.ExpirationTime := Now - (FJWT.LeewaySeconds + 1) * OneSecond;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Expired token is considered valid');
end;
procedure TTestJWT.TestHMAC;
@ -1104,6 +1145,29 @@ begin
end;
end;
procedure TTestJWT.TestIssuedAt;
var
lToken: string;
lError: string;
begin
FJWT.RegClaimsToChecks := [TJWTCheckableClaim.IssuedAt];
FJWT.Claims.IssuedAt := Yesterday;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered not valid');
FJWT.Claims.IssuedAt := Tomorrow;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Still-not-valid token is considered valid');
FJWT.Claims.IssuedAt := Now;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered not valid');
FJWT.Claims.IssuedAt := Now + (FJWT.LeewaySeconds + 1) * OneSecond;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Still-not-valid token is considered valid');
end;
procedure TTestJWT.TestLoadToken;
var
lToken: string;
@ -1113,7 +1177,7 @@ begin
FJWT.Claims.Subject := 'DelphiMVCFramework';
FJWT.Claims.Audience := 'DelphiDevelopers';
FJWT.Claims.IssuedAt := EncodeDateTime(2011, 11, 17, 17, 30, 0, 0);
FJWT.Claims.ExpirationTime := FJWT.Claims.IssuedAt + OneHour * 2;
FJWT.Claims.ExpirationTime := Now + OneHour * 2;
FJWT.Claims.NotBefore := EncodeDateTime(2011, 11, 17, 17, 30, 0, 0);
FJWT.Claims.JWT_ID := '123456';
FJWT.CustomClaims['username'] := 'dteti';
@ -1141,6 +1205,29 @@ begin
end;
procedure TTestJWT.TestNotBefore;
var
lToken: string;
lError: string;
begin
FJWT.RegClaimsToChecks := [TJWTCheckableClaim.NotBefore];
FJWT.Claims.NotBefore := Yesterday;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered not valid');
FJWT.Claims.NotBefore := Tomorrow;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Still-not-valid token is considered valid');
FJWT.Claims.NotBefore := Now;
lToken := FJWT.GetToken;
CheckTrue(FJWT.IsValidToken(lToken, lError), 'Valid token is considered not valid');
FJWT.Claims.NotBefore := Now + (FJWT.LeewaySeconds + 1) * OneSecond;
lToken := FJWT.GetToken;
CheckFalse(FJWT.IsValidToken(lToken, lError), 'Still-not-valid token is considered valid');
end;
procedure TTestJWT.TestStorage;
begin
FJWT.Claims.Issuer := 'bit Time Professionals';