/// shared DDD Infrastructure: implement an email validation service // - this unit is a part of the freeware Synopse mORMot framework, // licensed under a MPL/GPL/LGPL tri-license; version 1.18 unit dddInfraEmail; { This file is part of Synopse mORMot framework. Synopse mORMot framework. Copyright (C) 2022 Arnaud Bouchez Synopse Informatique - https://synopse.info *** BEGIN LICENSE BLOCK ***** Version: MPL 1.1/GPL 2.0/LGPL 2.1 The contents of this file are subject to the Mozilla Public License Version 1.1 (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.mozilla.org/MPL Software distributed under the License is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the specific language governing rights and limitations under the License. The Original Code is Synopse mORMot framework. The Initial Developer of the Original Code is Arnaud Bouchez. Portions created by the Initial Developer are Copyright (C) 2022 the Initial Developer. All Rights Reserved. Contributor(s): Alternatively, the contents of this file may be used under the terms of either the GNU General Public License Version 2 or later (the "GPL"), or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), in which case the provisions of the GPL or the LGPL are applicable instead of those above. If you wish to allow use of your version of this file only under the terms of either the GPL or the LGPL, and not to allow others to use your version of this file under the terms of the MPL, indicate your decision by deleting the provisions above and replace them with the notice and other provisions required by the GPL or the LGPL. If you do not delete the provisions above, a recipient may use your version of this file under the terms of any one of the MPL, the GPL or the LGPL. ***** END LICENSE BLOCK ***** } {$I Synopse.inc} // define HASINLINE CPU32 CPU64 OWNNORMTOUPPER interface uses SysUtils, SynCommons, SynTests, SynCrypto, SynTable, // for TSynFilter and TSynValidate mORMot, mORMotDDD, dddDomUserTypes, dddDomUserInterfaces; { ****************** Email Verification Service } type /// exception raised during any email process of this DDD's infrastructure // implementation EDDDEmail = class(EDDDInfraException); /// parameters used for the validation link of an email address // - may be stored as daemon/service level settings, using e.g. dddInfraSettings TDDDEmailRedirection = class(TSynPersistent) private fSuccessRedirectURI: RawUTF8; fRestServerPublicRootURI: RawUTF8; fValidationMethodName: RawUTF8; published /// the public URI which would be accessible from the Internet // - may be e.g 'http://publicserver/restroot' property RestServerPublicRootURI: RawUTF8 read fRestServerPublicRootURI write fRestServerPublicRootURI; /// the validation method name for the URI // - if not set, TDDDEmailValidationService will use 'EmailValidate' // - clickable URI would be RestServerPublicRootURI+'/'+ValidationMethodName property ValidationMethodName: RawUTF8 read fValidationMethodName write fValidationMethodName; /// the URI on which the browser will be redirected on validation success // - you can specify some '%' parameter markers, ordered as logon, email, // and validation IP // - may be e.g. 'http://publicwebsite/success&logon=%' property SuccessRedirectURI: RawUTF8 read fSuccessRedirectURI write fSuccessRedirectURI; end; /// parameters used for the validation/verification process of an email address // - may be stored as daemon/service level settings, using e.g. dddInfraSettings TDDDEmailValidation = class(TSynAutoCreateFields) private fTemplate: TDomUserEmailTemplate; fTemplateFolder: TFileName; fRedirection: TDDDEmailRedirection; public /// will fill some default values in the properties, if none is set procedure SetDefaultValuesIfVoid(const aSenderEmail,aApplication, aRedirectionURIPublicRoot,aRedirectionURISuccess: RawUTF8); published /// how the email should be created from a given template property Template: TDomUserEmailTemplate read fTemplate; /// where the template files are to be found property TemplateFolder: TFileName read fTemplateFolder write fTemplateFolder; /// parameters defining the validation link of an email address property Redirection: TDDDEmailRedirection read fRedirection; end; TSQLRecordEmailAbstract = class; TSQLRecordEmailValidation = class; TSQLRecordEmailValidationClass = class of TSQLRecordEmailValidation; /// abstract parent of any email-related service // - will define some common methods to validate an email address TDDDEmailServiceAbstract = class(TCQRSQueryObjectRest,IDomUserEmailCheck) protected fEmailValidate: TSynValidate; function CheckEmailCorrect(aEmail: TSQLRecordEmailAbstract; var aResult: TCQRSResult): boolean; virtual; procedure SetEmailValidate(const Value: TSynValidate); virtual; public constructor Create(aRest: TSQLRest); override; destructor Destroy; override; function CheckRecipient(const aEmail: RawUTF8): TCQRSResult; virtual; function CheckRecipients(const aEmails: TRawUTF8DynArray): TCQRSResult; published /// direct access to the email validation instance // - you can customize the default TSynValidateEmail to meet your own // expectations - once set, it will be owned by this class instance property EmailValidate: TSynValidate read fEmailValidate write SetEmailValidate; end; /// service used to validate an email address via an URL link to be clicked TDDDEmailValidationService = class(TDDDEmailServiceAbstract, IDomUserEmailValidation) protected fRestClass: TSQLRecordEmailValidationClass; fEMailer: IDomUserEmailer; fTemplate: IDomUserTemplate; fValidationSalt: integer; fValidationServerRoot: RawUTF8; fValidationMethodName: RawUTF8; fSuccessRedirectURI: RawUTF8; function GetEmailValidation(const aLogonName: RawUTF8): TSQLRecordEmailValidation; function GetWithSalt(const aLogonName,aEmail: RawUTF8; aSalt: integer): RawUTF8; procedure EmailValidate(Ctxt: TSQLRestServerURIContext); public /// initialize the validation service for a given ORM persistence // - would recognize the TSQLRecordEmailValidation class from aRest.Model // - will use aRest.Services for IoC, e.g. EMailer/Template properties constructor Create(aRest: TSQLRest); override; /// register the callback URI service procedure SetURIForServer(aRestServerPublic: TSQLRestServer; aParams: TDDDEmailRedirection); overload; /// register the callback URI service // - same as the overloaded function, but taking parameters one by one procedure SetURIForServer(aRestServerPublic: TSQLRestServer; const aRestServerPublicRootURI,aSuccessRedirectURI,aValidationMethodName: RawUTF8); overload; /// compute the target URI corresponding to SetURIForServer() parameters function ComputeURIForReply(const aLogonName,aEmail: RawUTF8): RawUTF8; /// check the supplied parameters, and send an email for validation function StartEmailValidation(const aTemplate: TDomUserEmailTemplate; const aLogonName,aEmail: RawUTF8): TCQRSResult; virtual; /// check if an email has been validated for a given logon function IsEmailValidated(const aLogonName,aEmail: RawUTF8): boolean; virtual; published /// will be injected (and freed) with the emailer service property EMailer: IDomUserEmailer read fEmailer; /// will be injected (and freed) with the email template service property Template: IDomUserTemplate read fTemplate; published /// the associated ORM class used to store the email validation process // - any class inheriting from TSQLRecordEmailValidation in the aRest.Model // will be recognized by Create(aRest) to store its information // - this temporary storage should not be the main user persistence domain property RestClass: TSQLRecordEmailValidationClass read fRestClass; /// the validation method name for the URI // - if not set, TDDDEmailValidationService will use 'EmailValidate' // - clickable URI would be ValidationServerRoot+'/'+ValidationMethodName property ValidationURI: RawUTF8 read fValidationMethodName; /// the public URI which would be accessible from the Internet // - may be e.g 'http://publicserver/restroot' property ValidationServerRoot: RawUTF8 read fValidationServerRoot; end; /// ORM class storing an email in addition to creation/modification timestamps // - declared as its own class, since may be reused TSQLRecordEmailAbstract = class(TSQLRecordTimed) private fEmail: RawUTF8; published /// the stored email address property Email: RawUTF8 read fEmail write fEmail; end; /// ORM class for email validation process // - we do not create a whole domain here, just an ORM persistence layer // - any class inheriting from TSQLRecordEmailValidation in the Rest.Model // will be recognized by TDDDEmailValidationService to store its information TSQLRecordEmailValidation = class(TSQLRecordEmailAbstract) protected fLogon: RawUTF8; fRequestTime: TTimeLog; fValidationSalt: Integer; fValidationTime: TTimeLog; fValidationIP: RawUTF8; public function IsValidated(const aEmail: RawUTF8): Boolean; published property Logon: RawUTF8 read fLogon write fLogon stored AS_UNIQUE; property RequestTime: TTimeLog read fRequestTime write fRequestTime; property ValidationSalt: Integer read fValidationSalt write fValidationSalt; property ValidationTime: TTimeLog read fValidationTime write fValidationTime; property ValidationIP: RawUTF8 read fValidationIP write fValidationIP; end; implementation { TDDDEmailServiceAbstract } constructor TDDDEmailServiceAbstract.Create(aRest: TSQLRest); begin inherited Create(aRest); fEmailValidate := TSynValidateEmail.Create; end; destructor TDDDEmailServiceAbstract.Destroy; begin fEmailValidate.Free; inherited; end; function TDDDEmailServiceAbstract.CheckEmailCorrect( aEmail: TSQLRecordEmailAbstract; var aResult: TCQRSResult): boolean; var msg: string; begin if (aEmail<>nil) and fEmailValidate.Process(0,aEmail.Email,msg) and aEmail.FilterAndValidate(Rest,msg) then result := true else begin CqrsSetResultString(cqrsDDDValidationFailed,msg,aResult); result := false; end; end; function TDDDEmailServiceAbstract.CheckRecipient( const aEmail: RawUTF8): TCQRSResult; var msg: string; begin CqrsBeginMethod(qaNone,result); if fEmailValidate.Process(0,aEmail,msg) then CqrsSetResult(cqrsSuccess,result) else CqrsSetResultString(cqrsDDDValidationFailed,msg,result); end; function TDDDEmailServiceAbstract.CheckRecipients( const aEmails: TRawUTF8DynArray): TCQRSResult; var msg: string; i: integer; begin CqrsBeginMethod(qaNone,result); for i := 0 to high(aEMails) do if not fEmailValidate.Process(0,aEmails[i],msg) then begin CqrsSetResultString(cqrsDDDValidationFailed,msg,result); exit; end; CqrsSetResult(cqrsSuccess,result); end; procedure TDDDEmailServiceAbstract.SetEmailValidate( const Value: TSynValidate); begin fEmailValidate.Free; fEmailValidate := Value; end; { TDDDEmailValidationService } constructor TDDDEmailValidationService.Create(aRest: TSQLRest); var rnd: Int64; begin inherited Create(aRest); // will inject aRest.Services for IoC fRestClass := fRest.Model.AddTableInherited(TSQLRecordEmailValidation); fRestClass.AddFilterNotVoidText(['Email','Logon']); rnd := GetTickCount64*PtrInt(self)*Random(MaxInt); fValidationSalt := crc32c(PtrInt(self),@rnd,sizeof(rnd)); end; function TDDDEmailValidationService.GetWithSalt(const aLogonName, aEmail: RawUTF8; aSalt: integer): RawUTF8; begin result := SHA256(FormatUTF8('%'#1'%'#2'%'#3,[aLogonName,aEmail,aSalt])); end; function TDDDEmailValidationService.ComputeURIForReply( const aLogonName, aEmail: RawUTF8): RawUTF8; begin result := aLogonName+#1+aEmail; result := fValidationServerRoot+fValidationMethodName+'/'+ GetWithSalt(aLogonName,aEmail,fValidationSalt)+ BinToBase64URI(pointer(result),length(result)); end; procedure TDDDEmailValidationService.EmailValidate( Ctxt: TSQLRestServerURIContext); var code: RawUTF8; logon,email,signature: RawUTF8; EmailValidation: TSQLRecordEmailValidation; begin signature := Copy(Ctxt.URIBlobFieldName,1,SHA256DIGESTSTRLEN); if length(signature)<>SHA256DIGESTSTRLEN then exit; code := Base64uriToBin(Copy(Ctxt.URIBlobFieldName,SHA256DIGESTSTRLEN+1,200)); Split(code,#1,logon,email); if (logon='') or (email='') then exit; EmailValidation := GetEmailValidation(logon); if EmailValidation<>nil then try if signature=GetWithSalt(logon,email,EmailValidation.ValidationSalt) then begin EmailValidation.ValidationTime := TimeLogNowUTC; EmailValidation.ValidationIP := Ctxt.InHeader['remoteip']; if Rest.Update(EmailValidation) then Ctxt.Redirect(FormatUTF8(fSuccessRedirectURI, [UrlEncode(logon),UrlEncode(email),UrlEncode(EmailValidation.ValidationIP)])); end; finally EmailValidation.Free; end; end; procedure TDDDEmailValidationService.SetURIForServer( aRestServerPublic: TSQLRestServer; aParams: TDDDEmailRedirection); begin if aParams=nil then raise EDDDEmail.CreateUTF8('Invalid %.SetURIForServer(%,nil)', [self,aRestServerPublic]); SetURIForServer(aRestServerPublic,aParams.RestServerPublicRootURI, aParams.SuccessRedirectURI,aParams.ValidationMethodName); end; procedure TDDDEmailValidationService.SetURIForServer( aRestServerPublic: TSQLRestServer; const aRestServerPublicRootURI, aSuccessRedirectURI, aValidationMethodName: RawUTF8); begin fSuccessRedirectURI := Trim(aSuccessRedirectURI); fValidationServerRoot := IncludeTrailingURIDelimiter(Trim(aRestServerPublicRootURI)); if (aRestServerPublic=nil) or (fSuccessRedirectURI='') or (fValidationServerRoot='') then raise EDDDEmail.CreateUTF8('Invalid %.SetURIForServer(%,"%","%")', [self,aRestServerPublic,fValidationServerRoot,fSuccessRedirectURI]); if not IdemPChar(pointer(fValidationServerRoot),'HTTP') then fValidationServerRoot := 'http://'+fValidationServerRoot; fValidationMethodName := Trim(aValidationMethodName); if fValidationMethodName='' then fValidationMethodName := 'EmailValidate'; // match method name by default aRestServerPublic.ServiceMethodRegister(fValidationMethodName,EmailValidate,true); end; function TDDDEmailValidationService.GetEmailValidation(const aLogonName: RawUTF8): TSQLRecordEmailValidation; begin result := RestClass.Create(Rest,'Logon=?',[aLogonName]); if result.fID=0 then FreeAndNil(result); end; function TDDDEmailValidationService.IsEmailValidated(const aLogonName, aEmail: RawUTF8): boolean; var EmailValidation: TSQLRecordEmailValidation; begin EmailValidation := GetEmailValidation(aLogonName); try result := EmailValidation.IsValidated(trim(aEmail)); finally EmailValidation.Free; end; end; function TDDDEmailValidationService.StartEmailValidation( const aTemplate: TDomUserEmailTemplate; const aLogonName, aEmail: RawUTF8): TCQRSResult; var EmailValidation: TSQLRecordEmailValidation; email,msg: RawUTF8; context: variant; begin email := Trim(aEmail); result := CheckRecipient(email); if result<>cqrsSuccess then exit; // supplied email address is invalid CqrsBeginMethod(qaNone,result); EmailValidation := GetEmailValidation(aLogonName); try if EmailValidation.IsValidated(email) then begin CqrsSetResultMsg(cqrsSuccess,'Already validated',result); exit; end; if EmailValidation=nil then begin EmailValidation := RestClass.Create; EmailValidation.Email := aEmail; EmailValidation.Logon := aLogonName; if not CheckEmailCorrect(EmailValidation,result) then exit; end else if EmailValidation.Email<>email then EmailValidation.Email := email; // allow validation for a new email EmailValidation.RequestTime := TimeLogNowUTC; EmailValidation.ValidationSalt := fValidationSalt; context := EmailValidation.GetSimpleFieldsAsDocVariant(true); _ObjAddProps(aTemplate,context); _ObjAddProps(['ValidationUri', ComputeURIForReply(EmailValidation.Logon,EmailValidation.Email)],context); msg := Template.ComputeMessage(context,aTemplate.FileName); if msg='' then CqrsSetResultMsg(cqrsInvalidContent, 'Impossible to render template [%]',[aTemplate.FileName],result) else if EMailer.SendEmail(TRawUTF8DynArrayFrom([aEmail]), aTemplate.SenderEmail,aTemplate.Subject,'',msg)=cqrsSuccess then if Rest.AddOrUpdate(EmailValidation)=0 then CqrsSetResultError(cqrsDataLayerError) else CqrsSetResultMsg(cqrsSuccess,'Validation email sent',result); finally EmailValidation.Free; end; end; { TSQLRecordEmailValidation } function TSQLRecordEmailValidation.IsValidated(const aEmail: RawUTF8): Boolean; begin result := (self<>nil) and (ValidationTime<>0) and (Email=aEmail); end; { TDDDEmailValidation } procedure TDDDEmailValidation.SetDefaultValuesIfVoid( const aSenderEmail,aApplication, aRedirectionURIPublicRoot,aRedirectionURISuccess: RawUTF8); begin if Template.SenderEmail='' then Template.SenderEmail := aSenderEmail; if Template.Application='' then Template.Application := aApplication; if Template.FileName='' then Template.FileName := 'EmailValidate.txt'; if (TemplateFolder='') and not FileExists(string(Template.FileName)) then FileFromString('Welcome to {{Application}}!'#13#10#13#10+ 'You have registered as "{{Logon}}", using {{EMail}} as contact address.'#13#10#13#10+ 'Please click on the following link to validate your email:'#13#10+ '{{ValidationUri}}'#13#10#13#10'Best regards from the clouds'#13#10#13#10+ '(please do not respond to this email)', UTF8ToString(Template.FileName)); if Template.Subject='' then Template.Subject := 'Please Validate Your Email'; if Redirection.RestServerPublicRootURI='' then Redirection.RestServerPublicRootURI := aRedirectionURIPublicRoot; if Redirection.SuccessRedirectURI='' then Redirection.SuccessRedirectURI := aRedirectionURISuccess; end; end.