feat(notification): important events notifications (#4644)

This adds important event notifications.
pull/4658/head
James Elliott 2022-12-27 19:59:08 +11:00 committed by GitHub
parent 1c3f650c72
commit f685f247cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 340 additions and 48 deletions

View File

@ -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`.__

View File

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

View File

@ -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"})
}

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ const (
const (
TemplateNameEmailIdentityVerification = "IdentityVerification"
TemplateNameEmailPasswordReset = "PasswordReset"
TemplateNameEmailEvent = "Event"
)
// Template Category Names.

View File

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

View File

@ -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...))
})
}
}

View File

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

View File

@ -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 @@
<tr>
<td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #333333; text-align:center; line-height: 30px;"
st-title="fulltext-content">
Hi {{ .DisplayName }} <br/>
Your password has been successfully reset.
Hi {{ .DisplayName }}
</td>
</tr>
<tr>
<td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #333333; text-align:center; line-height: 30px;"
st-title="fulltext-content">
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.
</td>
</tr>
<!-- End of Title -->
<!-- End of Title -->
<!-- spacing -->
<tr>
<td width="100%" height="20"
style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;">
&nbsp;</td>
</tr>
<!-- End of spacing -->
<!-- content -->
{{- $keys := sortAlpha (keys .Details) }}
{{- range $key := $keys }}
<tr>
<td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #666666; text-align:center; line-height: 30px;"
st-content="fulltext-content">
<b>{{ $key }}:</b> {{ index $.Details $key }}
</td>
</tr>
{{- end }}
<!-- End of content -->
</tbody>
</table>
</td>
</tr>
<!-- Spacing -->
<tr>
<td height="20"
style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;">&nbsp;
</td>
</tr>
<!-- Spacing -->
</tbody>
</table>
</td>

View File

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

View File

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