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 @@