diff --git a/docs/configuration/notifier/index.md b/docs/configuration/notifier/index.md index e2abbe9df..f48a2d702 100644 --- a/docs/configuration/notifier/index.md +++ b/docs/configuration/notifier/index.md @@ -16,6 +16,7 @@ verify their identity. ```yaml notifier: disable_startup_check: false + template_path: /path/to/templates/folder filesystem: {} smtp: {} ``` @@ -36,6 +37,52 @@ The notifier has a startup check which validates the specified provider configuration is correct and will be able to send emails. This can be disabled with the `disable_startup_check` option: +### template_path +
+type: string +{: .label .label-config .label-purple } +default: "" +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +This option allows the administrator to set custom templates for notifications +the templates folder should contain the following files + +|File |Description | +|------------------------|---------------------------------------------------| +|PasswordResetStep1.html |HTML Template for Step 1 of password reset process | +|PasswordResetStep1.txt |Text Template for Step 1 of password reset process | +|PasswordResetStep2.html |HTML Template for Step 2 of password reset process | +|PasswordResetStep2.txt |Text Template for Step 2 of password reset process | + +Note: +* if you don't define some of these files, a default template is used for that notification + + +In template files, you can use the following variables: + +|File |Description | +|------------------------|---------------------------------------------------| +|`{{.title}}`| A predefined title for the email.
It will be `"Reset your password"` or `"Password changed successfully"`, depending on the current step | +|`{{.url}}` | The url that allows to reset the user password | +|`{{.displayName}}` |The name of the user, i.e. `John Doe` | +|`{{.button}}` |The content for the password reset button, it's hardcoded to `Reset` | +|`{{.remoteIP}}` |The remote IP address that initiated the request or event | + +#### Example + +```html + +

{{.title}}

+ Hi {{.displayName}}
+ This email has been sent to you in order to validate your identity + Click here to change your password + +``` + + ### filesystem The [filesystem](filesystem.md) provider. diff --git a/internal/configuration/schema/notifier.go b/internal/configuration/schema/notifier.go index 29e62aea9..6bacf482b 100644 --- a/internal/configuration/schema/notifier.go +++ b/internal/configuration/schema/notifier.go @@ -31,6 +31,7 @@ type NotifierConfiguration struct { DisableStartupCheck bool `koanf:"disable_startup_check"` FileSystem *FileSystemNotifierConfiguration `koanf:"filesystem"` SMTP *SMTPNotifierConfiguration `koanf:"smtp"` + TemplatePath string `koanf:"template_path"` } // DefaultSMTPNotifierConfiguration represents default configuration parameters for the SMTP notifier. diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index e5d79eb8b..90019dc10 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -54,6 +54,9 @@ const ( errFmtNotifierMultipleConfigured = "notifier: please ensure only one of the 'smtp' or 'filesystem' notifier is configured" errFmtNotifierNotConfigured = "notifier: you must ensure either the 'smtp' or 'filesystem' notifier " + "is configured" + errFmtNotifierTemplatePathNotExist = "notifier: option 'template_path' refers to location '%s' which does not exist" + errFmtNotifierTemplatePathUnknownError = "notifier: option 'template_path' refers to location '%s' which couldn't be opened: %w" + errFmtNotifierTemplateLoad = "notifier: error loading template '%s': %w" errFmtNotifierFileSystemFileNameNotConfigured = "notifier: filesystem: option 'filename' is required " errFmtNotifierSMTPNotConfigured = "notifier: smtp: option '%s' is required" ) @@ -412,6 +415,7 @@ var ValidKeys = []string{ "notifier.smtp.tls.minimum_version", "notifier.smtp.tls.skip_verify", "notifier.smtp.tls.server_name", + "notifier.template_path", // Regulation Keys. "regulation.max_retries", diff --git a/internal/configuration/validator/notifier.go b/internal/configuration/validator/notifier.go index fbb21a18c..45216fa92 100644 --- a/internal/configuration/validator/notifier.go +++ b/internal/configuration/validator/notifier.go @@ -2,8 +2,12 @@ package validator import ( "fmt" + "os" + "path/filepath" + "text/template" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/templates" ) // ValidateNotifier validates and update notifier configuration. @@ -27,6 +31,54 @@ func ValidateNotifier(config *schema.NotifierConfiguration, validator *schema.St } validateSMTPNotifier(config.SMTP, validator) + + validateNotifierTemplates(config, validator) +} + +func validateNotifierTemplates(config *schema.NotifierConfiguration, validator *schema.StructValidator) { + if config.TemplatePath == "" { + return + } + + var ( + err error + t *template.Template + ) + + _, err = os.Stat(config.TemplatePath) + + switch { + case os.IsNotExist(err): + validator.Push(fmt.Errorf(errFmtNotifierTemplatePathNotExist, config.TemplatePath)) + return + case err != nil: + validator.Push(fmt.Errorf(errFmtNotifierTemplatePathUnknownError, config.TemplatePath, err)) + return + } + + if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameStep1+".html")); err == nil { + templates.HTMLEmailTemplateStep1 = t + } else { + validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameStep1+".html", err)) + } + + if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameStep1+".txt")); err == nil { + templates.PlainTextEmailTemplateStep1 = t + } else { + validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameStep1+".txt", err)) + } + + if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameStep2+".html")); err == nil { + templates.HTMLEmailTemplateStep2 = t + } else { + validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameStep2+".html", err)) + } + + if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameStep2+".txt")); err == nil { + templates.PlainTextEmailTemplateStep2 = t + } else { + validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameStep2+".txt", err)) + } } func validateSMTPNotifier(config *schema.SMTPNotifierConfiguration, validator *schema.StructValidator) { diff --git a/internal/handlers/handler_reset_password_step1.go b/internal/handlers/handler_reset_password_step1.go index 75cff5f0e..5b242b94e 100644 --- a/internal/handlers/handler_reset_password_step1.go +++ b/internal/handlers/handler_reset_password_step1.go @@ -28,8 +28,9 @@ func identityRetrieverFromStorage(ctx *middlewares.AutheliaCtx) (*session.Identi } return &session.Identity{ - Username: requestBody.Username, - Email: details.Emails[0], + Username: requestBody.Username, + Email: details.Emails[0], + DisplayName: details.DisplayName, }, nil } diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index a6fd44dee..19e3d5c89 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -1,9 +1,11 @@ package handlers import ( + "bytes" "fmt" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/templates" "github.com/authelia/authelia/v4/internal/utils" ) @@ -19,6 +21,8 @@ func ResetPasswordPost(ctx *middlewares.AutheliaCtx) { return } + username := *userSession.PasswordResetUsername + var requestBody resetPasswordStep2RequestBody err := ctx.ParseBody(&requestBody) @@ -32,7 +36,7 @@ func ResetPasswordPost(ctx *middlewares.AutheliaCtx) { return } - err = ctx.Providers.UserProvider.UpdatePassword(*userSession.PasswordResetUsername, requestBody.Password) + err = ctx.Providers.UserProvider.UpdatePassword(username, requestBody.Password) if err != nil { switch { @@ -46,7 +50,7 @@ func ResetPasswordPost(ctx *middlewares.AutheliaCtx) { return } - ctx.Logger.Debugf("Password of user %s has been reset", *userSession.PasswordResetUsername) + ctx.Logger.Debugf("Password of user %s has been reset", username) // Reset the request. userSession.PasswordResetUsername = nil @@ -57,5 +61,69 @@ func ResetPasswordPost(ctx *middlewares.AutheliaCtx) { return } - ctx.ReplyOK() + // Send Notification. + userInfo, err := ctx.Providers.UserProvider.GetDetails(username) + if err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } + + if len(userInfo.Emails) == 0 { + ctx.Logger.Error(fmt.Errorf("user %s has no email address configured", username)) + ctx.ReplyOK() + + return + } + + bufHTML := new(bytes.Buffer) + + disableHTML := false + if ctx.Configuration.Notifier != nil && ctx.Configuration.Notifier.SMTP != nil { + disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails + } + + if !disableHTML { + htmlParams := map[string]interface{}{ + "title": "Password changed successfully", + "displayName": userInfo.DisplayName, + "remoteIP": ctx.RemoteIP().String(), + } + + err = templates.HTMLEmailTemplateStep2.Execute(bufHTML, htmlParams) + + if err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } + } + + bufText := new(bytes.Buffer) + textParams := map[string]interface{}{ + "displayName": userInfo.DisplayName, + } + + err = templates.PlainTextEmailTemplateStep2.Execute(bufText, textParams) + + if err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } + + ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", + username, userInfo.Emails[0]) + + err = ctx.Providers.Notifier.Send(userInfo.Emails[0], "Password changed successfully", bufText.String(), bufHTML.String()) + + if err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } } diff --git a/internal/middlewares/identity_verification.go b/internal/middlewares/identity_verification.go index a46fba957..13d8f3498 100644 --- a/internal/middlewares/identity_verification.go +++ b/internal/middlewares/identity_verification.go @@ -79,12 +79,14 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim if !disableHTML { htmlParams := map[string]interface{}{ - "title": args.MailTitle, - "url": link, - "button": args.MailButtonContent, + "title": args.MailTitle, + "url": link, + "button": args.MailButtonContent, + "displayName": identity.DisplayName, + "remoteIP": ctx.RemoteIP().String(), } - err = templates.HTMLEmailTemplate.Execute(bufHTML, htmlParams) + err = templates.HTMLEmailTemplateStep1.Execute(bufHTML, htmlParams) if err != nil { ctx.Error(err, messageOperationFailed) @@ -94,10 +96,11 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim bufText := new(bytes.Buffer) textParams := map[string]interface{}{ - "url": link, + "url": link, + "displayName": identity.DisplayName, } - err = templates.PlainTextEmailTemplate.Execute(bufText, textParams) + err = templates.PlainTextEmailTemplateStep1.Execute(bufText, textParams) if err != nil { ctx.Error(err, messageOperationFailed) diff --git a/internal/session/types.go b/internal/session/types.go index 842973d3d..235df041b 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -55,8 +55,9 @@ type UserSession struct { // Identity identity of the user who is being verified. type Identity struct { - Username string - Email string + Username string + Email string + DisplayName string } func newRedisLogger() *redisLogger { diff --git a/internal/suites/example/compose/nginx/portal/docker-compose.yml b/internal/suites/example/compose/nginx/portal/docker-compose.yml index 517e192fb..f7461ef6c 100644 --- a/internal/suites/example/compose/nginx/portal/docker-compose.yml +++ b/internal/suites/example/compose/nginx/portal/docker-compose.yml @@ -6,6 +6,8 @@ services: volumes: - ./example/compose/nginx/portal/nginx.conf:/etc/nginx/nginx.conf - ./example/compose/nginx/portal/ssl:/etc/ssl + ports: + - 8080:8080 networks: authelianet: aliases: diff --git a/internal/templates/const.go b/internal/templates/const.go new file mode 100644 index 000000000..edaea45b2 --- /dev/null +++ b/internal/templates/const.go @@ -0,0 +1,7 @@ +package templates + +// Template File Names. +const ( + TemplateNameStep1 = "PasswordResetStep1" + TemplateNameStep2 = "PasswordResetStep2" +) diff --git a/internal/templates/html_email.go b/internal/templates/html_email_step_1.go similarity index 91% rename from internal/templates/html_email.go rename to internal/templates/html_email_step_1.go index 78470d212..ea18fe180 100644 --- a/internal/templates/html_email.go +++ b/internal/templates/html_email_step_1.go @@ -4,19 +4,19 @@ import ( "text/template" ) -// HTMLEmailTemplate the template of email that the user will receive for identity verification. -var HTMLEmailTemplate *template.Template +// HTMLEmailTemplateStep1 the template of email that the user will receive for identity verification. +var HTMLEmailTemplateStep1 *template.Template func init() { - t, err := template.New("html_email_template").Parse(emailHTMLContent) + t, err := template.New("html_email_template").Parse(emailHTMLContentStep1) if err != nil { panic(err) } - HTMLEmailTemplate = t + HTMLEmailTemplateStep1 = t } -const emailHTMLContent = ` +const emailHTMLContentStep1 = ` @@ -93,24 +93,27 @@ const emailHTMLContent = ` } a { - color: #ffffff; text-decoration: none; text-decoration: none !important; } - .link { - color: #0645AD; - } h1 { line-height: 30px; } .button { - padding: 15px 30px; - border-radius: 10px; - background: rgb(25, 118, 210); - text-decoration: none; + color: #ffffff; + padding: 15px 30px; + border-radius: 10px; + background: rgb(25, 118, 210); + text-decoration: none; } + + .link { + color: rgb(25, 118, 210); + text-decoration: none; + } + /*STYLES*/ table[class=full] { @@ -311,6 +314,12 @@ const emailHTMLContent = ` class="devicewidthinner"> + + + Hi {{.displayName}} + + @@ -333,17 +342,13 @@ const emailHTMLContent = ` {{.button}} +   - - - Or - - + {{.url}} @@ -354,13 +359,6 @@ const emailHTMLContent = ` - - -   - - - @@ -417,6 +415,19 @@ const emailHTMLContent = ` Please contact an administrator if you did not initiate the process. + + + +   + + + + + This email was generated by some with the IP address {{.remoteIP}}. + + diff --git a/internal/templates/html_email_step_2.go b/internal/templates/html_email_step_2.go new file mode 100644 index 000000000..ba3e4b5bd --- /dev/null +++ b/internal/templates/html_email_step_2.go @@ -0,0 +1,423 @@ +package templates + +import ( + "text/template" +) + +// HTMLEmailTemplateStep2 the template of email that the user will receive for identity verification. +var HTMLEmailTemplateStep2 *template.Template + +func init() { + t, err := template.New("html_email_template").Parse(emailHTMLContentStep2) + if err != nil { + panic(err) + } + + HTMLEmailTemplateStep2 = t +} + +const emailHTMLContentStep2 = ` + + + + + + + Authelia + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
  +
+ + + + + + + +
+

{{.title}}

+
+ +
  +
+
+
+ + + + + + + + +
+ + + + + + +
 
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
  +
+ + + + + + + + +
+ Hi {{.displayName}}
+ Your password has been successfully reset. + If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator. +
+
  +
+
+
+ + + + + + + + +
+ + + + + + + + + + + + +
 
 
 
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ Please contact an administrator if you did not initiate the process. +
+  
+ This email was generated by some with the IP address {{.remoteIP}}. +
+
+
+ + + + +` diff --git a/internal/templates/plaintext_email.go b/internal/templates/plaintext_email_step_1.go similarity index 62% rename from internal/templates/plaintext_email.go rename to internal/templates/plaintext_email_step_1.go index 0e855a9dc..22ffbd6bc 100644 --- a/internal/templates/plaintext_email.go +++ b/internal/templates/plaintext_email_step_1.go @@ -4,23 +4,25 @@ import ( "text/template" ) -// PlainTextEmailTemplate the template of email that the user will receive for identity verification. -var PlainTextEmailTemplate *template.Template +// PlainTextEmailTemplateStep1 the template of email that the user will receive for identity verification. +var PlainTextEmailTemplateStep1 *template.Template func init() { - t, err := template.New("text_email_template").Parse(emailPlainTextContent) + t, err := template.New("text_email_template").Parse(emailPlainTextContentStep1) if err != nil { panic(err) } - PlainTextEmailTemplate = t + PlainTextEmailTemplateStep1 = t } -const emailPlainTextContent = ` +const emailPlainTextContentStep1 = ` This email has been sent to you in order to validate your identity. If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator. To setup your 2FA please visit the following URL: {{.url}} +This email was generated by a user with the IP {{.remoteIP}}. + Please contact an administrator if you did not initiate the process. ` diff --git a/internal/templates/plaintext_email_step_2.go b/internal/templates/plaintext_email_step_2.go new file mode 100644 index 000000000..c378d81cf --- /dev/null +++ b/internal/templates/plaintext_email_step_2.go @@ -0,0 +1,26 @@ +package templates + +import ( + "text/template" +) + +// PlainTextEmailTemplateStep2 the template of email that the user will receive for identity verification. +var PlainTextEmailTemplateStep2 *template.Template + +func init() { + t, err := template.New("text_email_template").Parse(emailPlainTextContentStep2) + if err != nil { + panic(err) + } + + PlainTextEmailTemplateStep2 = t +} + +const emailPlainTextContentStep2 = ` +Your password has been successfully reset. +If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator. + +This email was generated by a user with the IP {{.remoteIP}}. + +Please contact an administrator if you did not initiate the process. +`