delphimvcframework/sources/MVCFramework.Middleware.JWT.pas

380 lines
13 KiB
ObjectPascal
Raw Normal View History

// ***************************************************************************
//
// Delphi MVC Framework
//
2019-01-08 12:48:27 +01:00
// Copyright (c) 2010-2019 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.Middleware.JWT;
{$I dmvcframework.inc}
interface
uses
System.SysUtils,
System.Classes,
System.Generics.Collections,
MVCFramework,
MVCFramework.Commons,
MVCFramework.JWT,
JsonDataObjects;
type
2017-03-20 19:08:01 +01:00
TJWTClaimsSetup = reference to procedure(const JWT: TJWT);
TMVCJWTAuthenticationMiddleware = class(TInterfacedObject, IMVCMiddleware)
private
FAuthenticationHandler: IMVCAuthenticationHandler;
FClaimsToChecks: TJWTCheckableClaims;
FSetupJWTClaims: TJWTClaimsSetup;
FSecret: string;
FLeewaySeconds: Cardinal;
FLoginURLSegment: string;
protected
function NeedsToBeExtended(const JWTValue: TJWT): Boolean;
procedure ExtendExpirationTime(const JWTValue: TJWT);
procedure InternalRender(AJSONOb: TJDOJsonObject; AContentType: string; AContentEncoding: string;
AContext: TWebContext; AInstanceOwner: Boolean = True);
procedure RenderError(const AErrorCode: UInt16; const AErrorMessage: string; const AContext: TWebContext;
const AErrorClassName: string = '');
procedure OnBeforeRouting(AContext: TWebContext; var AHandled: Boolean);
procedure OnBeforeControllerAction(AContext: TWebContext; const AControllerQualifiedClassName: string;
const AActionName: string; var AHandled: Boolean);
procedure OnAfterControllerAction(AContext: TWebContext; const AActionName: string; const AHandled: Boolean);
public
constructor Create(AAuthenticationHandler: IMVCAuthenticationHandler; AConfigClaims: TJWTClaimsSetup;
ASecret: string = 'D3lph1MVCFram3w0rk'; ALoginURLSegment: string = '/login';
AClaimsToCheck: TJWTCheckableClaims = [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt]; ALeewaySeconds: Cardinal = 300); virtual;
end;
implementation
uses
System.NetEncoding,
System.DateUtils,
System.Math, MVCFramework.Logger;
{ TMVCJWTAuthenticationMiddleware }
constructor TMVCJWTAuthenticationMiddleware.Create(AAuthenticationHandler: IMVCAuthenticationHandler;
AConfigClaims: TJWTClaimsSetup; ASecret: string = 'D3lph1MVCFram3w0rk'; ALoginURLSegment: string = '/login';
AClaimsToCheck: TJWTCheckableClaims = [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt]; ALeewaySeconds: Cardinal = 300);
begin
inherited Create;
FAuthenticationHandler := AAuthenticationHandler;
FSetupJWTClaims := AConfigClaims;
FClaimsToChecks := AClaimsToCheck;
FSecret := ASecret;
FLoginURLSegment := ALoginURLSegment;
FLeewaySeconds := ALeewaySeconds;
end;
procedure TMVCJWTAuthenticationMiddleware.ExtendExpirationTime(const JWTValue: TJWT);
begin
JWTValue.Claims.ExpirationTime := Max(JWTValue.Claims.ExpirationTime, Now) +
(JWTValue.LeewaySeconds + JWTValue.LiveValidityWindowInSeconds) * OneSecond;
end;
procedure TMVCJWTAuthenticationMiddleware.InternalRender(AJSONOb: TJDOJsonObject;
AContentType, AContentEncoding: string; AContext: TWebContext; AInstanceOwner: Boolean);
2017-03-20 19:08:01 +01:00
var
Encoding: TEncoding;
ContentType, JValue: string;
2017-03-20 19:08:01 +01:00
begin
JValue := AJSONOb.ToJSON;
2017-03-20 19:08:01 +01:00
AContext.Response.RawWebResponse.ContentType := AContentType + '; charset=' + AContentEncoding;
ContentType := AContentType + '; charset=' + AContentEncoding;
Encoding := TEncoding.GetEncoding(AContentEncoding);
2017-03-20 19:08:01 +01:00
try
AContext.Response.SetContentStream(TBytesStream.Create(TEncoding.Convert(TEncoding.Default, Encoding,
TEncoding.Default.GetBytes(JValue))), ContentType);
2017-03-20 19:08:01 +01:00
finally
Encoding.Free;
2017-03-20 19:08:01 +01:00
end;
if AInstanceOwner then
FreeAndNil(AJSONOb)
2017-03-20 19:08:01 +01:00
end;
function TMVCJWTAuthenticationMiddleware.NeedsToBeExtended(const JWTValue: TJWT): Boolean;
var
lWillExpireIn: Int64;
begin
lWillExpireIn := SecondsBetween(Now, JWTValue.Claims.ExpirationTime);
Result := lWillExpireIn <= JWTValue.LiveValidityWindowInSeconds;
// Log.Debug('--------------------------', 'EXPIRE');
// Log.DebugFmt('Now : %s', [TimeToStr(Now)], 'EXPIRE');
// Log.DebugFmt('ExpirationTime : %s', [TimeToStr(JWTValue.Claims.ExpirationTime)], 'EXPIRE');
// Log.DebugFmt('WillExpireIn : %d', [lWillExpireIn], 'EXPIRE');
// Log.DebugFmt('LVW : %d', [JWTValue.LiveValidityWindowInSeconds], 'EXPIRE');
// Log.DebugFmt('NeedsToBeExtened: %s', [BoolToStr(Result, True)], 'EXPIRE');
end;
procedure TMVCJWTAuthenticationMiddleware.OnAfterControllerAction(AContext: TWebContext; const AActionName: string;
const AHandled: Boolean);
begin
// Implement as needed
end;
procedure TMVCJWTAuthenticationMiddleware.OnBeforeControllerAction(AContext: TWebContext;
const AControllerQualifiedClassName, AActionName: string; var AHandled: Boolean);
var
AuthRequired: Boolean;
IsAuthorized: Boolean;
JWTValue: TJWT;
AuthHeader: string;
AuthToken: string;
ErrorMsg: string;
begin
// check if the resource is protected
FAuthenticationHandler.OnRequest(AContext, AControllerQualifiedClassName, AActionName, AuthRequired);
if not AuthRequired then
begin
AHandled := False;
Exit;
end;
// Checking token in subsequent requests
// ***************************************************
JWTValue := TJWT.Create(FSecret, FLeewaySeconds);
try
JWTValue.RegClaimsToChecks := Self.FClaimsToChecks;
AuthHeader := AContext.Request.Headers['Authentication'];
if AuthHeader.IsEmpty then
begin
RenderError(HTTP_STATUS.Unauthorized, 'Authentication Required', AContext);
AHandled := True;
Exit;
end;
// retrieve the token from the "authentication bearer" header
AuthToken := '';
if AuthHeader.StartsWith('bearer', True) then
begin
AuthToken := AuthHeader.Remove(0, 'bearer'.Length).Trim;
AuthToken := Trim(TNetEncoding.URL.Decode(AuthToken));
end;
// check the jwt
2017-09-22 09:33:21 +02:00
// if not JWTValue.IsValidToken(AuthToken, ErrorMsg) then
// begin
// RenderError(HTTP_STATUS.Unauthorized, ErrorMsg, AContext);
// AHandled := True;
// end
// else
if not JWTValue.LoadToken(AuthToken, ErrorMsg) then
begin
RenderError(HTTP_STATUS.Unauthorized, ErrorMsg, AContext);
AHandled := True;
2017-09-22 09:33:21 +02:00
Exit;
end;
if JWTValue.CustomClaims['username'].IsEmpty then
begin
RenderError(HTTP_STATUS.Unauthorized, 'Invalid Token, Authentication Required', AContext);
AHandled := True;
end
else
begin
2017-09-22 09:33:21 +02:00
IsAuthorized := False;
2017-09-22 09:33:21 +02:00
AContext.LoggedUser.UserName := JWTValue.CustomClaims['username'];
AContext.LoggedUser.Roles.AddRange(JWTValue.CustomClaims['roles'].Split([',']));
AContext.LoggedUser.LoggedSince := JWTValue.Claims.IssuedAt;
AContext.LoggedUser.CustomData := JWTValue.CustomClaims.AsCustomData;
FAuthenticationHandler.OnAuthorization(AContext, AContext.LoggedUser.Roles, AControllerQualifiedClassName,
AActionName, IsAuthorized);
2017-09-22 09:33:21 +02:00
if IsAuthorized then
begin
if JWTValue.LiveValidityWindowInSeconds > 0 then
begin
if NeedsToBeExtended(JWTValue) then
begin
ExtendExpirationTime(JWTValue);
AContext.Response.SetCustomHeader('Authentication', 'bearer ' + JWTValue.GetToken);
end;
end;
2017-09-22 09:33:21 +02:00
AHandled := False
end
else
begin
RenderError(HTTP_STATUS.Forbidden, 'Authorization Forbidden', AContext);
AHandled := True;
end;
end;
finally
JWTValue.Free;
end;
end;
procedure TMVCJWTAuthenticationMiddleware.OnBeforeRouting(AContext: TWebContext; var AHandled: Boolean);
var
UserName: string;
Password: string;
RolesList: TList<string>;
SessionData: TSessionData;
IsValid: Boolean;
JWTValue: TJWT;
lCustomPair: TPair<string, string>;
LObj: TJDOJsonObject;
begin
if SameText(AContext.Request.PathInfo, FLoginURLSegment) and (AContext.Request.HTTPMethod = httpPOST) then
begin
UserName := TNetEncoding.URL.Decode(AContext.Request.Headers['jwtusername']);
Password := TNetEncoding.URL.Decode(AContext.Request.Headers['jwtpassword']);
if (UserName.IsEmpty) or (Password.IsEmpty) then
begin
RenderError(HTTP_STATUS.Unauthorized, 'Username and password Required', AContext);
AHandled := True;
Exit;
end;
// check the authorization for the requested resource
RolesList := TList<string>.Create;
try
SessionData := TSessionData.Create;
try
try
FAuthenticationHandler.OnAuthentication(AContext, UserName, Password, RolesList, IsValid, SessionData);
if IsValid then
begin
JWTValue := TJWT.Create(FSecret, FLeewaySeconds);
try
// let's user config claims and custom claims
if not Assigned(FSetupJWTClaims) then
raise EMVCJWTException.Create('SetupJWTClaims not set');
FSetupJWTClaims(JWTValue);
// these claims are mandatory and managed by the middleware
if not JWTValue.CustomClaims['username'].IsEmpty then
raise EMVCJWTException.Create
('Custom claim "username" is reserved and cannot be modified in the JWT setup');
if not JWTValue.CustomClaims['roles'].IsEmpty then
raise EMVCJWTException.Create
('Custom claim "roles" is reserved and cannot be modified in the JWT setup');
JWTValue.CustomClaims['username'] := UserName;
JWTValue.CustomClaims['roles'] := string.Join(',', RolesList.ToArray);
if JWTValue.LiveValidityWindowInSeconds > 0 then
begin
if NeedsToBeExtended(JWTValue) then
begin
ExtendExpirationTime(JWTValue);
end;
end;
// setup the current logged user from the JWT
AContext.LoggedUser.Roles.AddRange(RolesList);
AContext.LoggedUser.UserName := JWTValue.CustomClaims['username'];
AContext.LoggedUser.LoggedSince := JWTValue.Claims.IssuedAt;
AContext.LoggedUser.Realm := JWTValue.Claims.Subject;
if SessionData.Count > 0 then
begin
AContext.LoggedUser.CustomData := TMVCCustomData.Create;
for lCustomPair in SessionData do
begin
AContext.LoggedUser.CustomData.AddOrSetValue(lCustomPair.Key, lCustomPair.Value);
if not JWTValue.CustomClaims.Items[lCustomPair.Key].IsEmpty then
raise EMVCJWTException.CreateFmt('JWT Error: "%s" is a reserved key name', [lCustomPair.Key]);
JWTValue.CustomClaims.Items[lCustomPair.Key] := lCustomPair.Value;
end;
end;
LObj := TJDOJsonObject.Create;
try
LObj.S['token'] := JWTValue.GetToken;
InternalRender(LObj, TMVCMediaType.APPLICATION_JSON, TMVCConstants.DEFAULT_CONTENT_CHARSET,
AContext, False);
finally
LObj.Free;
end;
AHandled := True;
finally
JWTValue.Free;
end;
end
else
begin
RenderError(HTTP_STATUS.Forbidden, 'Forbidden', AContext);
AHandled := True;
end;
except
on E: Exception do
begin
RenderError(HTTP_STATUS.Forbidden, E.Message, AContext);
AHandled := True;
Exit;
end;
end;
finally
SessionData.Free;
end;
finally
RolesList.Free;
end;
end;
end;
procedure TMVCJWTAuthenticationMiddleware.RenderError(const AErrorCode: UInt16; const AErrorMessage: string;
const AContext: TWebContext; const AErrorClassName: string);
var
LJo: TJDOJsonObject;
LStatus: string;
begin
AContext.Response.StatusCode := AErrorCode;
AContext.Response.ReasonString := AErrorMessage;
LStatus := 'error';
if (AErrorCode div 100) = 2 then
LStatus := 'ok';
LJo := TJDOJsonObject.Create;
LJo.S['status'] := LStatus;
if AErrorClassName = '' then
LJo.Values['classname'] := nil
else
LJo.S['classname'] := AErrorClassName;
LJo.S['message'] := AErrorMessage;
InternalRender(LJo, TMVCConstants.DEFAULT_CONTENT_TYPE, TMVCConstants.DEFAULT_CONTENT_CHARSET, AContext);
end;
end.