diff --git a/docs/content/en/reference/guides/templating.md b/docs/content/en/reference/guides/templating.md index 29a4383e6..c95e9329d 100644 --- a/docs/content/en/reference/guides/templating.md +++ b/docs/content/en/reference/guides/templating.md @@ -50,9 +50,15 @@ The following functions which mimic the behaviour of helm exist in most templati - sha512sum - squote - now +- keys +- sortAlpha +- b64enc +- b64dec +- b32enc +- b32dec See the [Helm Documentation](https://helm.sh/docs/chart_template_guide/function_list/) for more information. Please -note that only the functions listed above are supported. +note that only the functions listed above are supported and the functions don't necessarily behave exactly the same. __*Special Note:* The `env` and `expandenv` function automatically excludes environment variables that start with `AUTHELIA_` or `X_AUTHELIA_` and end with one of `KEY`, `SECRET`, `PASSWORD`, `TOKEN`, or `CERTIFICATE_CHAIN`.__ diff --git a/internal/handlers/handler_register_totp.go b/internal/handlers/handler_register_totp.go index 6d9942c1c..160a32961 100644 --- a/internal/handlers/handler_register_totp.go +++ b/internal/handlers/handler_register_totp.go @@ -46,8 +46,7 @@ func totpIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { ctx.Error(fmt.Errorf("unable to generate TOTP key: %s", err), messageUnableToRegisterOneTimePassword) } - err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, *config) - if err != nil { + if err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, *config); err != nil { ctx.Error(fmt.Errorf("unable to save TOTP secret in DB: %s", err), messageUnableToRegisterOneTimePassword) return } @@ -57,10 +56,11 @@ func totpIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { Base32Secret: string(config.Secret), } - err = ctx.SetJSONBody(response) - if err != nil { + if err = ctx.SetJSONBody(response); err != nil { ctx.Logger.Errorf("Unable to set TOTP key response in body: %s", err) } + + ctxLogEvent(ctx, username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Time-based One Time Password"}) } // TOTPIdentityFinish the handler for finishing the identity validation. diff --git a/internal/handlers/handler_register_webauthn.go b/internal/handlers/handler_register_webauthn.go index 60f89d254..8a3a8cfc0 100644 --- a/internal/handlers/handler_register_webauthn.go +++ b/internal/handlers/handler_register_webauthn.go @@ -147,12 +147,10 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { userSession.Webauthn = nil if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the attestation challenge", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return } ctx.ReplyOK() ctx.SetStatusCode(fasthttp.StatusCreated) + + ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Device Name": "Primary"}) } diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index e7e2a6b5d..9683bfb4f 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -73,10 +73,13 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { return } - data := templates.EmailPasswordResetValues{ + data := templates.EmailEventValues{ Title: "Password changed successfully", DisplayName: userInfo.DisplayName, RemoteIP: ctx.RemoteIP().String(), + Details: map[string]any{ + "Action": "Password Reset", + }, } addresses := userInfo.Addresses() @@ -84,7 +87,7 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", username, addresses[0]) - if err = ctx.Providers.Notifier.Send(ctx, addresses[0], "Password changed successfully", ctx.Providers.Templates.GetPasswordResetEmailTemplate(), data); err != nil { + if err = ctx.Providers.Notifier.Send(ctx, addresses[0], "Password changed successfully", ctx.Providers.Templates.GetEventEmailTemplate(), data); err != nil { ctx.Logger.Error(err) ctx.ReplyOK() diff --git a/internal/handlers/util.go b/internal/handlers/util.go index 3117b74bf..431bbc0ce 100644 --- a/internal/handlers/util.go +++ b/internal/handlers/util.go @@ -2,9 +2,12 @@ package handlers import ( "bytes" + "fmt" "net/url" + "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/templates" ) var bytesEmpty = []byte("") @@ -24,3 +27,41 @@ func ctxGetPortalURL(ctx *middlewares.AutheliaCtx) (portalURL *url.URL) { return nil } + +func ctxLogEvent(ctx *middlewares.AutheliaCtx, username, description string, eventDetails map[string]any) { + var ( + details *authentication.UserDetails + err error + ) + + ctx.Logger.Debugf("Getting user details for notification") + + // Send Notification. + if details, err = ctx.Providers.UserProvider.GetDetails(username); err != nil { + ctx.Logger.Error(err) + return + } + + if len(details.Emails) == 0 { + ctx.Logger.Error(fmt.Errorf("user %s has no email address configured", username)) + return + } + + data := templates.EmailEventValues{ + Title: description, + DisplayName: details.DisplayName, + RemoteIP: ctx.RemoteIP().String(), + Details: eventDetails, + } + + ctx.Logger.Debugf("Getting user addresses for notification") + + addresses := details.Addresses() + + ctx.Logger.Debugf("Sending an email to user %s (%s) to inform them of an important event.", username, addresses[0]) + + if err = ctx.Providers.Notifier.Send(ctx, addresses[0], description, ctx.Providers.Templates.GetEventEmailTemplate(), data); err != nil { + ctx.Logger.Error(err) + return + } +} diff --git a/internal/notification/smtp_auth.go b/internal/notification/smtp_auth.go index fb5d14f46..f8ea04e52 100644 --- a/internal/notification/smtp_auth.go +++ b/internal/notification/smtp_auth.go @@ -5,7 +5,7 @@ import ( "net/smtp" "strings" - gomail "github.com/wneessen/go-mail" + "github.com/wneessen/go-mail" "github.com/wneessen/go-mail/auth" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -29,7 +29,7 @@ func NewOpportunisticSMTPAuth(config *schema.SMTPNotifierConfiguration) *Opportu type OpportunisticSMTPAuth struct { username, password, host string - satPreference []gomail.SMTPAuthType + satPreference []mail.SMTPAuthType sa smtp.Auth } @@ -43,11 +43,11 @@ func (a *OpportunisticSMTPAuth) Start(server *smtp.ServerInfo) (proto string, to for _, pref := range a.satPreference { if utils.IsStringInSlice(string(pref), server.Auth) { switch pref { - case gomail.SMTPAuthPlain: + case mail.SMTPAuthPlain: a.sa = smtp.PlainAuth("", a.username, a.password, a.host) - case gomail.SMTPAuthLogin: + case mail.SMTPAuthLogin: a.sa = auth.LoginAuth(a.username, a.password, a.host) - case gomail.SMTPAuthCramMD5: + case mail.SMTPAuthCramMD5: a.sa = smtp.CRAMMD5Auth(a.username, a.password) } @@ -57,12 +57,12 @@ func (a *OpportunisticSMTPAuth) Start(server *smtp.ServerInfo) (proto string, to if a.sa == nil { for _, sa := range server.Auth { - switch gomail.SMTPAuthType(sa) { - case gomail.SMTPAuthPlain: + switch mail.SMTPAuthType(sa) { + case mail.SMTPAuthPlain: a.sa = smtp.PlainAuth("", a.username, a.password, a.host) - case gomail.SMTPAuthLogin: + case mail.SMTPAuthLogin: a.sa = auth.LoginAuth(a.username, a.password, a.host) - case gomail.SMTPAuthCramMD5: + case mail.SMTPAuthCramMD5: a.sa = smtp.CRAMMD5Auth(a.username, a.password) } } diff --git a/internal/templates/const.go b/internal/templates/const.go index 4990dd5b2..bf7bffa71 100644 --- a/internal/templates/const.go +++ b/internal/templates/const.go @@ -9,6 +9,7 @@ const ( const ( TemplateNameEmailIdentityVerification = "IdentityVerification" TemplateNameEmailPasswordReset = "PasswordReset" + TemplateNameEmailEvent = "Event" ) // Template Category Names. diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 564080d8a..dc4547d03 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -4,11 +4,14 @@ import ( "crypto/sha1" //nolint:gosec "crypto/sha256" "crypto/sha512" + "encoding/base32" + "encoding/base64" "encoding/hex" "fmt" "hash" "os" "reflect" + "sort" "strconv" "strings" "time" @@ -27,6 +30,8 @@ func FuncMap() map[string]any { "hasPrefix": FuncStringHasPrefix, "hasSuffix": FuncStringHasSuffix, "lower": strings.ToLower, + "keys": FuncKeys, + "sortAlpha": FuncSortAlpha, "upper": strings.ToUpper, "title": strings.ToTitle, "trim": strings.TrimSpace, @@ -40,9 +45,43 @@ func FuncMap() map[string]any { "sha512sum": FuncHashSum(sha512.New), "squote": FuncStringSQuote, "now": time.Now, + "b64enc": FuncB64Enc, + "b64dec": FuncB64Dec, + "b32enc": FuncB32Enc, + "b32dec": FuncB32Dec, } } +// FuncB64Enc is a helper function that provides similar functionality to the helm b64enc func. +func FuncB64Enc(input string) string { + return base64.StdEncoding.EncodeToString([]byte(input)) +} + +// FuncB64Dec is a helper function that provides similar functionality to the helm b64dec func. +func FuncB64Dec(input string) (string, error) { + data, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return "", err + } + + return string(data), nil +} + +// FuncB32Enc is a helper function that provides similar functionality to the helm b32enc func. +func FuncB32Enc(input string) string { + return base32.StdEncoding.EncodeToString([]byte(input)) +} + +// FuncB32Dec is a helper function that provides similar functionality to the helm b32dec func. +func FuncB32Dec(input string) (string, error) { + data, err := base32.StdEncoding.DecodeString(input) + if err != nil { + return "", err + } + + return string(data), nil +} + // FuncExpandEnv is a special version of os.ExpandEnv that excludes secret keys. func FuncExpandEnv(s string) string { return os.Expand(s, FuncGetEnv) @@ -68,6 +107,35 @@ func FuncHashSum(new func() hash.Hash) func(data string) string { } } +// FuncKeys is a helper function that provides similar functionality to the helm keys func. +func FuncKeys(maps ...map[string]any) []string { + var keys []string + + for _, m := range maps { + for k := range m { + keys = append(keys, k) + } + } + + return keys +} + +// FuncSortAlpha is a helper function that provides similar functionality to the helm sortAlpha func. +func FuncSortAlpha(slice any) []string { + kind := reflect.Indirect(reflect.ValueOf(slice)).Kind() + + switch kind { + case reflect.Slice, reflect.Array: + unsorted := strslice(slice) + sorted := sort.StringSlice(unsorted) + sorted.Sort() + + return sorted + } + + return []string{strval(slice)} +} + // FuncStringReplace is a helper function that provides similar functionality to the helm replace func. func FuncStringReplace(old, new, s string) string { return strings.ReplaceAll(s, old, new) @@ -114,7 +182,7 @@ func FuncStringSQuote(in ...any) string { for _, s := range in { if s != nil { - out = append(out, fmt.Sprintf("%q", strval(s))) + out = append(out, fmt.Sprintf("'%s'", strval(s))) } } diff --git a/internal/templates/funcs_test.go b/internal/templates/funcs_test.go index 91a1a500b..d6907ace8 100644 --- a/internal/templates/funcs_test.go +++ b/internal/templates/funcs_test.go @@ -325,3 +325,147 @@ func TestFuncStringSplitList(t *testing.T) { }) } } + +func TestFuncKeys(t *testing.T) { + testCases := []struct { + name string + have []map[string]any + expected []string + }{ + {"ShouldProvideKeysSingle", []map[string]any{{"a": "v", "b": "v", "z": "v"}}, []string{"a", "b", "z"}}, + {"ShouldProvideKeysMultiple", []map[string]any{{"a": "v", "b": "v", "z": "v"}, {"h": "v"}}, []string{"a", "b", "z", "h"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + keys := FuncKeys(tc.have...) + + assert.Len(t, keys, len(tc.expected)) + + for _, expected := range tc.expected { + assert.Contains(t, keys, expected) + } + }) + } +} + +func TestFuncSortAlpha(t *testing.T) { + testCases := []struct { + name string + have any + expected []string + }{ + {"ShouldSortStrings", []string{"a", "c", "b"}, []string{"a", "b", "c"}}, + {"ShouldSortIntegers", []int{2, 3, 1}, []string{"1", "2", "3"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncSortAlpha(tc.have)) + }) + } +} + +func TestFuncBEnc(t *testing.T) { + testCases := []struct { + name string + have string + expected32 string + expected64 string + }{ + {"ShouldEncodeEmptyString", "", "", ""}, + {"ShouldEncodeString", "abc", "MFRGG===", "YWJj"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("Base32", func(t *testing.T) { + assert.Equal(t, tc.expected32, FuncB32Enc(tc.have)) + }) + + t.Run("Base64", func(t *testing.T) { + assert.Equal(t, tc.expected64, FuncB64Enc(tc.have)) + }) + }) + } +} + +func TestFuncBDec(t *testing.T) { + testCases := []struct { + name string + have string + err32, expected32 string + err64, expected64 string + }{ + {"ShouldDecodeEmptyString", "", "", "", "", ""}, + {"ShouldDecodeBase32", "MFRGG===", "", "abc", "illegal base64 data at input byte 5", ""}, + {"ShouldDecodeBase64", "YWJj", "illegal base32 data at input byte 3", "", "", "abc"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var ( + actual string + err error + ) + + t.Run("Base32", func(t *testing.T) { + actual, err = FuncB32Dec(tc.have) + + if tc.err32 != "" { + assert.Equal(t, "", actual) + assert.EqualError(t, err, tc.err32) + } else { + assert.Equal(t, tc.expected32, actual) + assert.NoError(t, err) + } + }) + + t.Run("Base64", func(t *testing.T) { + actual, err = FuncB64Dec(tc.have) + + if tc.err64 != "" { + assert.Equal(t, "", actual) + assert.EqualError(t, err, tc.err64) + } else { + assert.Equal(t, tc.expected64, actual) + assert.NoError(t, err) + } + }) + }) + } +} + +func TestFuncStringQuote(t *testing.T) { + testCases := []struct { + name string + have []any + expected string + }{ + {"ShouldQuoteSingleValue", []any{"abc"}, `"abc"`}, + {"ShouldQuoteMultiValue", []any{"abc", 123}, `"abc" "123"`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringQuote(tc.have...)) + }) + } +} + +func TestFuncStringSQuote(t *testing.T) { + testCases := []struct { + name string + have []any + expected string + }{ + {"ShouldQuoteSingleValue", []any{"abc"}, `'abc'`}, + {"ShouldQuoteMultiValue", []any{"abc", 123}, `'abc' '123'`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringSQuote(tc.have...)) + }) + } +} diff --git a/internal/templates/provider.go b/internal/templates/provider.go index 3f94ad8c6..3c122aea2 100644 --- a/internal/templates/provider.go +++ b/internal/templates/provider.go @@ -23,9 +23,8 @@ type Provider struct { templates Templates } -// GetPasswordResetEmailTemplate returns the EmailTemplate for Password Reset notifications. -func (p *Provider) GetPasswordResetEmailTemplate() (t *EmailTemplate) { - return p.templates.notification.passwordReset +func (p *Provider) GetEventEmailTemplate() (t *EmailTemplate) { + return p.templates.notification.event } // GetIdentityVerificationEmailTemplate returns the EmailTemplate for Identity Verification notifications. @@ -40,7 +39,7 @@ func (p *Provider) load() (err error) { errs = append(errs, err) } - if p.templates.notification.passwordReset, err = loadEmailTemplate(TemplateNameEmailPasswordReset, p.config.EmailTemplatesPath); err != nil { + if p.templates.notification.event, err = loadEmailTemplate(TemplateNameEmailEvent, p.config.EmailTemplatesPath); err != nil { errs = append(errs, err) } diff --git a/internal/templates/src/notification/PasswordReset.html b/internal/templates/src/notification/Event.html similarity index 86% rename from internal/templates/src/notification/PasswordReset.html rename to internal/templates/src/notification/Event.html index e463e0ac1..377cacb04 100644 --- a/internal/templates/src/notification/PasswordReset.html +++ b/internal/templates/src/notification/Event.html @@ -74,26 +74,28 @@ } 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] { width: 100%; @@ -296,23 +298,39 @@ - Hi {{ .DisplayName }}
- Your password has been successfully reset. + Hi {{ .DisplayName }} + + + + + This email has been sent to you in order to notify you of an important event. If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator. - + + + + +   + + + + {{- $keys := sortAlpha (keys .Details) }} + {{- range $key := $keys }} + + + {{ $key }}: {{ index $.Details $key }} + + + {{- end }} + - - -   - - - diff --git a/internal/templates/src/notification/PasswordReset.txt b/internal/templates/src/notification/Event.txt similarity index 53% rename from internal/templates/src/notification/PasswordReset.txt rename to internal/templates/src/notification/Event.txt index 1bab735d2..db141f68b 100644 --- a/internal/templates/src/notification/PasswordReset.txt +++ b/internal/templates/src/notification/Event.txt @@ -1,7 +1,15 @@ -Your password has been successfully reset. +This email has been sent to you in order to notify you of an important event. If you did not initiate the process your credentials might have been compromised and you should reset your password and contact an administrator. +{{- if ne (len .Details) 0 }} +{{- $keys := sortAlpha (keys .Details) }} +Details: +{{- range $key := $keys }} + {{ $key }}: {{ index $.Details $key }} +{{- end }} +{{- end }} + This email was generated by a user with the IP {{ .RemoteIP }}. Please contact an administrator if you did not initiate this process. diff --git a/internal/templates/types.go b/internal/templates/types.go index 105bb60d4..9dd01de5c 100644 --- a/internal/templates/types.go +++ b/internal/templates/types.go @@ -13,8 +13,8 @@ type Templates struct { // NotificationTemplates are the templates for the notification system. type NotificationTemplates struct { - passwordReset *EmailTemplate identityVerification *EmailTemplate + event *EmailTemplate } // Template covers shared implementations between the text and html template.Template. @@ -36,9 +36,16 @@ type EmailTemplate struct { Text *tt.Template } +// EmailEventValues are the values used for event templates. +type EmailEventValues struct { + Title string + DisplayName string + Details map[string]any + RemoteIP string +} + // EmailPasswordResetValues are the values used for password reset templates. type EmailPasswordResetValues struct { - UUID string Title string DisplayName string RemoteIP string @@ -46,7 +53,6 @@ type EmailPasswordResetValues struct { // EmailIdentityVerificationValues are the values used for the identity verification templates. type EmailIdentityVerificationValues struct { - UUID string Title string DisplayName string RemoteIP string