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 object Form7: TForm7
Left = 0 Left = 0
Top = 0 Top = 0
Caption = 'Form7' Caption = 'JWT Sample'
ClientHeight = 231 ClientHeight = 160
ClientWidth = 505 ClientWidth = 505
Color = clBtnFace Color = clBtnFace
Font.Charset = DEFAULT_CHARSET Font.Charset = DEFAULT_CHARSET

View File

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

View File

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

View File

@ -3,9 +3,13 @@ unit MVCFramework.JWT;
interface interface
uses uses
System.Generics.Collections; System.Generics.Collections, System.JSON;
type type
{$SCOPEDENUMS ON}
TJWTCheckableClaim = (ExpirationTime, NotBefore, IssuedAt);
TJWTCheckableClaims = set of TJWTCheckableClaim;
TJWTRegisteredClaimNames = class sealed TJWTRegisteredClaimNames = class sealed
public public
const const
@ -152,22 +156,31 @@ type
FRegisteredClaims: TJWTRegisteredClaims; FRegisteredClaims: TJWTRegisteredClaims;
FCustomClaims: TJWTCustomClaims; FCustomClaims: TJWTCustomClaims;
FHMACAlgorithm: String; FHMACAlgorithm: String;
FLeewaySeconds: Int64;
FRegClaimsToChecks: TJWTCheckableClaims;
procedure SetHMACAlgorithm(const Value: String); 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 public
constructor Create(const SecretKey: String); virtual; constructor Create(const SecretKey: String); virtual;
destructor Destroy; override; destructor Destroy; override;
function GetToken: String; function GetToken: String;
function IsValidToken(const Token: String): Boolean; function IsValidToken(const Token: String; out Error: String): Boolean;
procedure LoadToken(const Token: String); procedure LoadToken(const Token: String);
property Claims: TJWTRegisteredClaims read FRegisteredClaims; property Claims: TJWTRegisteredClaims read FRegisteredClaims;
property CustomClaims: TJWTCustomClaims read FCustomClaims; property CustomClaims: TJWTCustomClaims read FCustomClaims;
property HMACAlgorithm: String read FHMACAlgorithm write SetHMACAlgorithm; property HMACAlgorithm: String read FHMACAlgorithm write SetHMACAlgorithm;
property LeewaySeconds: Int64 read FLeewaySeconds write SetLeewaySeconds;
property RegClaimsToChecks: TJWTCheckableClaims read FRegClaimsToChecks write SetChecks;
end; end;
implementation implementation
uses uses
System.SysUtils, System.JSON, MVCFramework.Commons, MVCFramework.HMAC, System.DateUtils; System.SysUtils, MVCFramework.Commons, MVCFramework.HMAC, System.DateUtils;
{ TJWTRegisteredClaims } { TJWTRegisteredClaims }
@ -293,6 +306,94 @@ end;
{ TJWT } { 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); constructor TJWT.Create(const SecretKey: String);
begin begin
inherited Create; inherited Create;
@ -300,6 +401,9 @@ begin
FRegisteredClaims := TJWTRegisteredClaims.Create; FRegisteredClaims := TJWTRegisteredClaims.Create;
FCustomClaims := TJWTCustomClaims.Create; FCustomClaims := TJWTCustomClaims.Create;
FHMACAlgorithm := 'HS256'; FHMACAlgorithm := 'HS256';
FLeewaySeconds := 300; // 5 minutes of leeway
FRegClaimsToChecks := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt];
end; end;
destructor TJWT.Destroy; destructor TJWT.Destroy;
@ -325,7 +429,15 @@ begin
for lRegClaimName in TJWTRegisteredClaimNames.Names do for lRegClaimName in TJWTRegisteredClaimNames.Names do
begin begin
if FRegisteredClaims.Contains(lRegClaimName) then 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; end;
for lCustomClaimName in FCustomClaims.Keys do for lCustomClaimName in FCustomClaims.Keys do
@ -347,35 +459,61 @@ begin
end; end;
end; end;
function TJWT.IsValidToken(const Token: String): Boolean; function TJWT.IsValidToken(const Token: String; out Error: String): Boolean;
var var
lPieces: TArray<String>; lPieces: TArray<String>;
lJHeader: TJSONObject; lJHeader: TJSONObject;
lJAlg: TJSONString; lJAlg: TJSONString;
lAlgName: string; lAlgName: string;
lJPayload: TJSONObject; lJPayload: TJSONObject;
lError: String;
begin begin
lPieces := Token.Split(['.']); lPieces := Token.Split(['.']);
if Length(lPieces) <> 3 then if Length(lPieces) <> 3 then
Exit(False); Exit(false);
lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject; lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject;
try try
if not Assigned(lJHeader) then if not Assigned(lJHeader) then
Exit(False); Exit(false);
lJPayload := TJSONObject.ParseJSONValue(B64Decode(lPieces[1])) as TJSONObject; lJPayload := TJSONObject.ParseJSONValue(B64Decode(lPieces[1])) as TJSONObject;
try try
if not Assigned(lJPayload) then if not Assigned(lJPayload) then
Exit(False); Exit(false);
if not lJHeader.TryGetValue<TJSONString>('alg', lJAlg) then if not lJHeader.TryGetValue<TJSONString>('alg', lJAlg) then
Exit(False); Exit(false);
lAlgName := lJAlg.Value; lAlgName := lJAlg.Value;
Result := Token = lPieces[0] + '.' + lPieces[1] + '.' + Result := Token = lPieces[0] + '.' + lPieces[1] + '.' +
B64Encode( B64Encode(
HMAC(lAlgName, lPieces[0] + '.' + lPieces[1], FSecretKey) 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 finally
lJPayload.Free; lJPayload.Free;
end; end;
@ -395,9 +533,10 @@ var
j: Integer; j: Integer;
lIsRegistered: Boolean; lIsRegistered: Boolean;
lValue: string; lValue: string;
lError: String;
begin begin
if not IsValidToken(Token) then if not IsValidToken(Token, lError) then
raise EMVCJWTException.Create('Invalid token'); raise EMVCJWTException.Create('Invalid token: ' + lError);
lPieces := Token.Split(['.']); lPieces := Token.Split(['.']);
lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject; lJHeader := TJSONObject.ParseJSONValue(B64Decode(lPieces[0])) as TJSONObject;
@ -413,7 +552,7 @@ begin
FCustomClaims.FClaims.Clear; FCustomClaims.FClaims.Clear;
for i := 0 to lJPayload.Count - 1 do for i := 0 to lJPayload.Count - 1 do
begin begin
lIsRegistered := False; lIsRegistered := false;
lJPair := lJPayload.Pairs[i]; lJPair := lJPayload.Pairs[i];
lName := lJPair.JsonString.Value; lName := lJPair.JsonString.Value;
lValue := lJPair.JsonValue.Value; lValue := lJPair.JsonValue.Value;
@ -440,9 +579,19 @@ begin
end; end;
end; end;
procedure TJWT.SetChecks(const Value: TJWTCheckableClaims);
begin
FRegClaimsToChecks := Value;
end;
procedure TJWT.SetHMACAlgorithm(const Value: String); procedure TJWT.SetHMACAlgorithm(const Value: String);
begin begin
FHMACAlgorithm := Value; FHMACAlgorithm := Value;
end; end;
procedure TJWT.SetLeewaySeconds(const Value: Int64);
begin
FLeewaySeconds := Value;
end;
end. end.

View File

@ -71,6 +71,10 @@ type
procedure TestStorage; procedure TestStorage;
procedure TestCreateAndValidateToken; procedure TestCreateAndValidateToken;
procedure TestLoadToken; procedure TestLoadToken;
procedure TestNotBefore;
procedure TestExpirationTime;
procedure TestIssuedAt;
procedure TestDefaults;
end; end;
implementation implementation
@ -1078,15 +1082,52 @@ end;
procedure TTestJWT.TestCreateAndValidateToken; procedure TTestJWT.TestCreateAndValidateToken;
var var
lToken: string; lToken: string;
lError: string;
begin begin
FJWT.Claims.Issuer := 'bit Time Professionals'; FJWT.Claims.Issuer := 'bit Time Professionals';
FJWT.Claims.Subject := 'DelphiMVCFramework'; FJWT.Claims.Subject := 'DelphiMVCFramework';
FJWT.Claims.JWT_ID := TGUID.NewGuid.ToString; FJWT.Claims.JWT_ID := TGUID.NewGuid.ToString;
FJWT.CustomClaims['username'] := 'dteti'; FJWT.CustomClaims['username'] := 'dteti';
FJWT.CustomClaims['userrole'] := 'admin'; FJWT.CustomClaims['userrole'] := 'admin';
FJWT.Claims.ExpirationTime := Tomorrow;
FJWT.Claims.IssuedAt := Yesterday;
FJWT.Claims.NotBefore := Yesterday;
lToken := FJWT.GetToken; lToken := FJWT.GetToken;
// TFile.WriteAllText('jwt_token.dat', lToken); // 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; end;
procedure TTestJWT.TestHMAC; procedure TTestJWT.TestHMAC;
@ -1104,6 +1145,29 @@ begin
end; end;
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; procedure TTestJWT.TestLoadToken;
var var
lToken: string; lToken: string;
@ -1113,7 +1177,7 @@ begin
FJWT.Claims.Subject := 'DelphiMVCFramework'; FJWT.Claims.Subject := 'DelphiMVCFramework';
FJWT.Claims.Audience := 'DelphiDevelopers'; FJWT.Claims.Audience := 'DelphiDevelopers';
FJWT.Claims.IssuedAt := EncodeDateTime(2011, 11, 17, 17, 30, 0, 0); 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.NotBefore := EncodeDateTime(2011, 11, 17, 17, 30, 0, 0);
FJWT.Claims.JWT_ID := '123456'; FJWT.Claims.JWT_ID := '123456';
FJWT.CustomClaims['username'] := 'dteti'; FJWT.CustomClaims['username'] := 'dteti';
@ -1141,6 +1205,29 @@ begin
end; 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; procedure TTestJWT.TestStorage;
begin begin
FJWT.Claims.Issuer := 'bit Time Professionals'; FJWT.Claims.Issuer := 'bit Time Professionals';