delphimvcframework/sources/MVCFramework.JWT.pas
Daniele Teti fc72c8c49b Some minor fixes.
All protected serializers methods are now public so that is possible to use the low level serialization as was possibile with the old ObjectsMappers.
2017-09-07 00:11:25 +02:00

675 lines
21 KiB
ObjectPascal

// ***************************************************************************
//
// Delphi MVC Framework
//
// Copyright (c) 2010-2017 Daniele Teti and the DMVCFramework Team
//
// https://github.com/danieleteti/delphimvcframework
//
// ***************************************************************************
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// *************************************************************************** }
unit MVCFramework.JWT;
{$I dmvcframework.inc}
interface
uses
System.Generics.Collections,
MVCFramework,
MVCFramework.TypesAliases,
MVCFramework.Patches;
type
{$SCOPEDENUMS ON}
TJWTCheckableClaim = (ExpirationTime, NotBefore, IssuedAt);
TJWTCheckableClaims = set of TJWTCheckableClaim;
TJWTRegisteredClaimNames = class sealed
public
const
Issuer: string = 'iss';
Subject: string = 'sub';
Audience: string = 'aud';
ExpirationTime: string = 'exp';
NotBefore: string = 'nbf';
IssuedAt: string = 'iat';
JWT_ID: string = 'jti';
Names: array [0 .. 6] of string = (
'iss',
'sub',
'aud',
'exp',
'nbf',
'iat',
'jti');
end;
TJWTDictionaryObject = class
private
FClaims: TDictionary<string, string>;
function GetItem(const Index: string): string;
procedure SetItem(const Index, Value: string);
function GetItemAsDateTime(const Index: string): TDateTime;
procedure SetItemAsDateTime(const Index: string; const Value: TDateTime);
property ItemsAsDateTime[const index: string]: TDateTime read GetItemAsDateTime write SetItemAsDateTime;
property Items[const index: string]: string read GetItem write SetItem; default;
protected
function Contains(const Index: string): Boolean;
function Keys: TArray<string>;
public
constructor Create; virtual;
destructor Destroy; override;
end;
/// <summary>
/// https://tools.ietf.org/html/rfc7519#section-4.1.1
/// </summary>
TJWTRegisteredClaims = class(TJWTDictionaryObject)
private
procedure SetAudience(const Value: string);
procedure SetExpirationTime(const Value: TDateTime);
procedure SetIssuedAt(const Value: TDateTime);
procedure SetISSUER(const Value: string);
procedure SetJWT_ID(const Value: string);
procedure SetNotBefore(const Value: TDateTime);
procedure SetSubject(const Value: string);
function GetAudience: string;
function GetExpirationTime: TDateTime;
function GetIssuedAt: TDateTime;
function GetJWT_ID: string;
function GetNotBefore: TDateTime;
function GetSubject: string;
function GetIssuer: string;
public
/// <summary>
/// "iss" (Issuer) Claim
/// The " iss "(issuer) claim identifies The principal that issued The
/// JWT. The processing of this claim is generally application specific.
/// The " iss " value is a case-sensitive string containing a StringOrURI
/// value.Use of this claim is OPTIONAL.
/// </summary>
property Issuer: string read GetIssuer write SetISSUER;
/// <summary>
/// "sub" (Subject) Claim
/// The "sub" (subject) claim identifies the principal that is the
/// subject of the JWT. The claims in a JWT are normally statements
/// about the subject. The subject value MUST either be scoped to be
/// locally unique in the context of the issuer or be globally unique.
/// The processing of this claim is generally application specific. The
/// "sub" value is a case-sensitive string containing a StringOrURI
/// value. Use of this claim is OPTIONAL.
/// </summary>
property Subject: string read GetSubject write SetSubject;
/// <summary>
/// "aud" (Audience) Claim
/// The "aud" (audience) claim identifies the recipients that the JWT is
/// intended for. Each principal intended to process the JWT MUST
/// identify itself with a value in the audience claim. If the principal
/// processing the claim does not identify itself with a value in the
/// "aud" claim when this claim is present, then the JWT MUST be
/// rejected. In the general case, the "aud" value is an array of case-
/// sensitive strings, each containing a StringOrURI value. In the
/// special case when the JWT has one audience, the "aud" value MAY be a
/// single case-sensitive string containing a StringOrURI value. The
/// interpretation of audience values is generally application specific.
/// Use of this claim is OPTIONAL.
/// </summary>
property Audience: string read GetAudience write SetAudience;
/// <summary>
/// "exp" (Expiration Time) Claim
/// The "exp" (expiration time) claim identifies the expiration time on
/// or after which the JWT MUST NOT be accepted for processing. The
/// processing of the "exp" claim requires that the current date/time
/// MUST be before the expiration date/time listed in the "exp" claim.
/// Implementers MAY provide for some small leeway, usually no more than
/// a few minutes, to account for clock skew. Its value MUST be a number
/// containing a NumericDate value. Use of this claim is OPTIONAL.
/// </summary>
property ExpirationTime: TDateTime read GetExpirationTime write SetExpirationTime;
/// <summary>
/// "nbf" (Not Before) Claim
/// The "nbf" (not before) claim identifies the time before which the JWT
/// MUST NOT be accepted for processing. The processing of the "nbf"
/// claim requires that the current date/time MUST be after or equal to
/// the not-before date/time listed in the "nbf" claim. Implementers MAY
/// provide for some small leeway, usually no more than a few minutes, to
/// account for clock skew. Its value MUST be a number containing a
/// NumericDate value. Use of this claim is OPTIONAL.
/// </summary>
property NotBefore: TDateTime read GetNotBefore write SetNotBefore;
/// <summary>
/// "iat" (Issued At) Claim
/// The "iat" (issued at) claim identifies the time at which the JWT was
/// issued. This claim can be used to determine the age of the JWT. Its
/// value MUST be a number containing a NumericDate value. Use of this
/// claim is OPTIONAL.
/// </summary>
property IssuedAt: TDateTime read GetIssuedAt write SetIssuedAt;
/// <summary>
/// "jti" (JWT ID) Claim
/// The "jti" (JWT ID) claim provides a unique identifier for the JWT.
/// The identifier value MUST be assigned in a manner that ensures that
/// there is a negligible probability that the same value will be
/// accidentally assigned to a different data object; if the application
/// uses multiple issuers, collisions MUST be prevented among values
/// produced by different issuers as well. The "jti" claim can be used
/// to prevent the JWT from being replayed. The "jti" value is a case-
/// sensitive string. Use of this claim is OPTIONAL.
/// </summary>
property JWT_ID: string read GetJWT_ID write SetJWT_ID;
end;
TJWTCustomClaims = class(TJWTDictionaryObject)
property Items; default;
function AsCustomData: TMVCCustomData;
end;
TJWT = class
private
FSecretKey: string;
FRegisteredClaims: TJWTRegisteredClaims;
FCustomClaims: TJWTCustomClaims;
FHMACAlgorithm: string;
FRegClaimsToChecks: TJWTCheckableClaims;
FLeewaySeconds: Cardinal;
procedure SetHMACAlgorithm(const Value: string);
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;
procedure SetLiveValidityWindowInSeconds(const Value: Cardinal);
function GetLiveValidityWindowInSeconds: Cardinal;
public
constructor Create(const SecretKey: string; const ALeewaySeconds: Cardinal = 300); virtual;
destructor Destroy; override;
function GetToken: string;
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: Cardinal read FLeewaySeconds;
property RegClaimsToChecks: TJWTCheckableClaims read FRegClaimsToChecks write SetChecks;
/// <summary>
/// Use LiveValidityWindowInSeconds to make the ExpirationTime dynamic at each request,
/// incrementing the ExpirationTime by LiveValidityWindowInSeconds seconds at each request
/// </summary>
property LiveValidityWindowInSeconds: Cardinal read GetLiveValidityWindowInSeconds write SetLiveValidityWindowInSeconds;
end;
implementation
uses
System.SysUtils
, MVCFramework.Commons
, MVCFramework.HMAC
, System.DateUtils;
{ TJWTRegisteredClaims }
function TJWTRegisteredClaims.GetAudience: string;
begin
Result := Items[TJWTRegisteredClaimNames.Audience];
end;
function TJWTRegisteredClaims.GetExpirationTime: TDateTime;
begin
Result := ItemsAsDateTime[TJWTRegisteredClaimNames.ExpirationTime];
end;
function TJWTRegisteredClaims.GetIssuedAt: TDateTime;
begin
Result := ItemsAsDateTime[TJWTRegisteredClaimNames.IssuedAt];
end;
function TJWTRegisteredClaims.GetIssuer: string;
begin
Result := Items[TJWTRegisteredClaimNames.Issuer];
end;
function TJWTRegisteredClaims.GetJWT_ID: string;
begin
Result := Items[TJWTRegisteredClaimNames.JWT_ID];
end;
function TJWTRegisteredClaims.GetNotBefore: TDateTime;
begin
Result := ItemsAsDateTime[TJWTRegisteredClaimNames.NotBefore];
end;
function TJWTRegisteredClaims.GetSubject: string;
begin
Result := Items[TJWTRegisteredClaimNames.Subject];
end;
procedure TJWTRegisteredClaims.SetAudience(const Value: string);
begin
Items[TJWTRegisteredClaimNames.Audience] := Value;
end;
procedure TJWTRegisteredClaims.SetExpirationTime(const Value: TDateTime);
begin
ItemsAsDateTime[TJWTRegisteredClaimNames.ExpirationTime] := Value;
end;
procedure TJWTRegisteredClaims.SetIssuedAt(const Value: TDateTime);
begin
ItemsAsDateTime[TJWTRegisteredClaimNames.IssuedAt] := Value;
end;
procedure TJWTRegisteredClaims.SetISSUER(const Value: string);
begin
Items[TJWTRegisteredClaimNames.Issuer] := Value;
end;
procedure TJWTRegisteredClaims.SetJWT_ID(const Value: string);
begin
Items[TJWTRegisteredClaimNames.JWT_ID] := Value;
end;
procedure TJWTRegisteredClaims.SetNotBefore(const Value: TDateTime);
begin
ItemsAsDateTime[TJWTRegisteredClaimNames.NotBefore] := Value;
end;
procedure TJWTRegisteredClaims.SetSubject(const Value: string);
begin
Items[TJWTRegisteredClaimNames.Subject] := Value;
end;
{ TJWTCustomClaims }
function TJWTDictionaryObject.Contains(const Index: string): Boolean;
begin
Result := FClaims.ContainsKey(index);
end;
constructor TJWTDictionaryObject.Create;
begin
inherited;
FClaims := TDictionary<string, string>.Create;
end;
destructor TJWTDictionaryObject.Destroy;
begin
FClaims.Free;
inherited;
end;
function TJWTDictionaryObject.GetItem(const Index: string): string;
begin
if not FClaims.TryGetValue(index, Result) then
Result := '';
end;
function TJWTDictionaryObject.GetItemAsDateTime(const Index: string): TDateTime;
var
lIntValue: Int64;
begin
if not TryStrToInt64(Items[index], lIntValue) then
raise Exception.Create('Item cannot be converted as Unix Epoch');
Result := UnixToDateTime(lIntValue, False);
end;
function TJWTDictionaryObject.Keys: TArray<string>;
begin
Result := FClaims.Keys.ToArray;
end;
procedure TJWTDictionaryObject.SetItem(const Index, Value: string);
begin
FClaims.AddOrSetValue(index, Value);
end;
procedure TJWTDictionaryObject.SetItemAsDateTime(const Index: string;
const Value: TDateTime);
begin
Items[index] := IntToStr(DateTimeToUnix(Value, False));
end;
{ TJWTCustomClaims }
function TJWTCustomClaims.AsCustomData: TMVCCustomData;
begin
Result := TMVCCustomData.Create(FClaims);
end;
{ TJWT }
function TJWT.CheckExpirationTime(Payload: TJSONObject;
out Error: string): Boolean;
var
lJValue: TJSONValue;
lIntValue: Int64;
lValue: string;
lExpirationTimeAsDateTime: TDateTime;
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;
lExpirationTimeAsDateTime := UnixToDateTime(lIntValue, False);
if lExpirationTimeAsDateTime <= 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, False) >= 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, False) >= Now + FLeewaySeconds * OneSecond then
begin
Error := 'Token still not valid';
Exit(False);
end;
Result := True;
end;
constructor TJWT.Create(const SecretKey: string; const ALeewaySeconds: Cardinal = 300);
begin
inherited Create;
FSecretKey := SecretKey;
FRegisteredClaims := TJWTRegisteredClaims.Create;
FCustomClaims := TJWTCustomClaims.Create;
FHMACAlgorithm := 'HS256';
FLeewaySeconds := ALeewaySeconds;
FRegClaimsToChecks := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt];
end;
destructor TJWT.Destroy;
begin
FRegisteredClaims.Free;
FCustomClaims.Free;
inherited;
end;
function TJWT.GetLiveValidityWindowInSeconds: Cardinal;
begin
Result := StrToIntDef(FCustomClaims.Items['lvw'], 0);
end;
function TJWT.GetToken: string;
var
lHeader, lPayload: TJSONObject;
lHeaderEncoded, lPayloadEncoded, lToken, lHash: string;
lBytes: TBytes;
lRegClaimName: string;
lCustomClaimName: string;
begin
lHeader := TJSONObject.Create;
try
lPayload := TJSONObject.Create;
try
lHeader.AddPair('alg', HMACAlgorithm).AddPair('typ', 'JWT');
for lRegClaimName in TJWTRegisteredClaimNames.Names do
begin
if FRegisteredClaims.Contains(lRegClaimName) then
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
begin
lPayload.AddPair(lCustomClaimName, FCustomClaims[lCustomClaimName]);
end;
lHeaderEncoded := URLSafeB64encode(lHeader.ToString, False);
lPayloadEncoded := URLSafeB64encode(lPayload.ToString, False);
lToken := lHeaderEncoded + '.' + lPayloadEncoded;
lBytes := HMAC(HMACAlgorithm, lToken, FSecretKey);
lHash := URLSafeB64encode(lBytes, false);
Result := lToken + '.' + lHash;
finally
lPayload.Free;
end;
finally
lHeader.Free;
end;
end;
function TJWT.IsValidToken(const Token: string; out Error: string): Boolean;
var
lPieces: TArray<string>;
lJHeader: TJSONObject;
lJAlg: TJSONString;
lAlgName: string;
lJPayload: TJSONObject;
begin
Error := '';
lPieces := Token.Split(['.']);
if Length(lPieces) <> 3 then
begin
Error := 'Invalid Token';
Exit(False);
end;
lJHeader := TJSONObject.ParseJSONValue(URLSafeB64Decode(lPieces[0])) as TJSONObject;
try
if not Assigned(lJHeader) then
begin
Error := 'Invalid Token';
Exit(False);
end;
lJPayload := TJSONObject.ParseJSONValue(URLSafeB64Decode(lPieces[1])) as TJSONObject;
try
if not Assigned(lJPayload) then
begin
Error := 'Invalid Token';
Exit(False);
end;
if not lJHeader.TryGetValue<TJSONString>('alg', lJAlg) then
begin
Error := 'Invalid Token';
Exit(False);
end;
lAlgName := lJAlg.Value;
Result := Token = lPieces[0] + '.' + lPieces[1] + '.' +
URLSafeB64encode(
HMAC(lAlgName, lPieces[0] + '.' + lPieces[1], FSecretKey),
False
);
// 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, Error) then
begin
Exit(False);
end;
end;
if TJWTCheckableClaim.NotBefore in RegClaimsToChecks then
begin
if not CheckNotBefore(lJPayload, Error) then
begin
Exit(False);
end;
end;
if TJWTCheckableClaim.IssuedAt in RegClaimsToChecks then
begin
if not CheckIssuedAt(lJPayload, Error) then
begin
Exit(False);
end;
end;
end;
finally
lJPayload.Free;
end;
finally
lJHeader.Free;
end;
end;
procedure TJWT.LoadToken(const Token: string);
var
lPieces: TArray<string>;
lJHeader: TJSONObject;
lJPayload: TJSONObject;
lJPair: TJSONPair;
i: Integer;
lName: string;
j: Integer;
lIsRegistered: Boolean;
lValue: string;
lError: string;
begin
if not IsValidToken(Token, lError) then
raise EMVCJWTException.Create(lError);
lPieces := Token.Split(['.']);
lJHeader := TJSONObject.ParseJSONValue(URLSafeB64Decode(lPieces[0])) as TJSONObject;
try
lJPayload := TJSONObject.ParseJSONValue(URLSafeB64Decode(lPieces[1])) as TJSONObject;
try
// loading data from token into self
FHMACAlgorithm := lJHeader.Values['alg'].Value;
// registered claims
FRegisteredClaims.FClaims.Clear;
// custom claims
FCustomClaims.FClaims.Clear;
for i := 0 to lJPayload.Count - 1 do
begin
lIsRegistered := False;
lJPair := lJPayload.Pairs[i];
lName := lJPair.JsonString.Value;
lValue := lJPair.JsonValue.Value;
// if is a registered claim, load it in the proper dictionary...
for j := 0 to high(TJWTRegisteredClaimNames.Names) do
begin
if lName = TJWTRegisteredClaimNames.Names[j] then
begin
FRegisteredClaims.FClaims.AddOrSetValue(lName, lValue);
lIsRegistered := True;
Break;
end;
end;
if not lIsRegistered then
FCustomClaims.FClaims.AddOrSetValue(lName, lValue);
end;
FCustomClaims.FClaims.TrimExcess;
FRegisteredClaims.FClaims.TrimExcess;
finally
lJPayload.Free;
end;
finally
lJHeader.Free;
end;
end;
procedure TJWT.SetChecks(const Value: TJWTCheckableClaims);
begin
FRegClaimsToChecks := Value;
end;
procedure TJWT.SetHMACAlgorithm(const Value: string);
begin
FHMACAlgorithm := Value;
end;
procedure TJWT.SetLiveValidityWindowInSeconds(const Value: Cardinal);
begin
FCustomClaims.Items['lvw'] := Value.ToString;
end;
end.