From e1225fdcbebf5b2f7ca3c8aa239cb6eb4ebbe989 Mon Sep 17 00:00:00 2001 From: danieleteti Date: Sat, 21 May 2016 21:56:12 +0200 Subject: [PATCH] ADDED JWT Checks for: NotBefore, ExpirationTime, IssuedAt Updated JWT sample Added more unit tests --- .../client/MainClientFormU.dfm | 4 +- .../client/MainClientFormU.pas | 5 +- .../server/MyControllerU.pas | 14 +- sources/MVCFramework.JWT.pas | 173 ++++++++++++++++-- unittests/Several/FrameworkTestsU.pas | 91 ++++++++- 5 files changed, 266 insertions(+), 21 deletions(-) diff --git a/samples/jsonwebtokenplain/client/MainClientFormU.dfm b/samples/jsonwebtokenplain/client/MainClientFormU.dfm index 3da0c078..81c6fbba 100644 --- a/samples/jsonwebtokenplain/client/MainClientFormU.dfm +++ b/samples/jsonwebtokenplain/client/MainClientFormU.dfm @@ -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 diff --git a/samples/jsonwebtokenplain/client/MainClientFormU.pas b/samples/jsonwebtokenplain/client/MainClientFormU.pas index 3e4934f8..467502d0 100644 --- a/samples/jsonwebtokenplain/client/MainClientFormU.pas +++ b/samples/jsonwebtokenplain/client/MainClientFormU.pas @@ -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; diff --git a/samples/jsonwebtokenplain/server/MyControllerU.pas b/samples/jsonwebtokenplain/server/MyControllerU.pas index 2f8852a9..ece60e7b 100644 --- a/samples/jsonwebtokenplain/server/MyControllerU.pas +++ b/samples/jsonwebtokenplain/server/MyControllerU.pas @@ -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; diff --git a/sources/MVCFramework.JWT.pas b/sources/MVCFramework.JWT.pas index 93e26a2c..f7f990bb 100644 --- a/sources/MVCFramework.JWT.pas +++ b/sources/MVCFramework.JWT.pas @@ -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; 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('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. diff --git a/unittests/Several/FrameworkTestsU.pas b/unittests/Several/FrameworkTestsU.pas index 832af5b8..08fecb5b 100644 --- a/unittests/Several/FrameworkTestsU.pas +++ b/unittests/Several/FrameworkTestsU.pas @@ -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';