mirror of
https://github.com/danieleteti/delphimvcframework.git
synced 2024-11-15 15:55:54 +01:00
ADDED JWT Checks for: NotBefore, ExpirationTime, IssuedAt
Updated JWT sample Added more unit tests
This commit is contained in:
parent
214d378cdc
commit
e1225fdcbe
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user