feat: add option to skip TLS certificate check and ability to send test email

This commit is contained in:
Elias Schneider
2024-11-21 18:24:01 +01:00
parent a1302ef7bf
commit 653d948f73
14 changed files with 203 additions and 33 deletions

View File

@@ -0,0 +1,11 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -0,0 +1,3 @@
{{ define "base" -}}
This is a test email.
{{ end -}}

View File

@@ -30,7 +30,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Initialize services // Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, templateDir) emailService, err := service.NewEmailService(appConfigService, db, templateDir)
if err != nil { if err != nil {
log.Fatalf("Unable to create email service: %s", err) log.Fatalf("Unable to create email service: %s", err)
} }
@@ -58,7 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -14,10 +14,13 @@ import (
func NewAppConfigController( func NewAppConfigController(
group *gin.RouterGroup, group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) { appConfigService *service.AppConfigService,
emailService *service.EmailService,
) {
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
emailService: emailService,
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -29,10 +32,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler) group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
} }
type AppConfigController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
emailService *service.EmailService
} }
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -22,4 +22,5 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
} }

View File

@@ -19,10 +19,11 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
EmailEnabled AppConfigVariable EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable
SmtpPort AppConfigVariable SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
} }

View File

@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string

View File

@@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{
Key: "smtpPassword", Key: "smtpPassword",
Type: "string", Type: "string",
}, },
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {

View File

@@ -2,14 +2,18 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
@@ -17,11 +21,12 @@ import (
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template textTemplates map[string]*ttemplate.Template
} }
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) { func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths) htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err) return nil, fmt.Errorf("prepare html templates: %w", err)
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
return &EmailService{ return &EmailService{
appConfigService: appConfigService, appConfigService: appConfigService,
db: db,
htmlTemplates: htmlTemplates, htmlTemplates: htmlTemplates,
textTemplates: textTemplates, textTemplates: textTemplates,
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail() error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
return err
}
return SendEmail(srv,
email.Address{
Email: user.Email,
Name: user.FullName(),
}, TestTemplate, nil)
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set // Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" { if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
@@ -71,26 +90,100 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
) )
c.Body(body) c.Body(body)
// Set up the authentication information. // Set up the TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
}
// Connect to the SMTP server
port := srv.appConfigService.DbConfig.SmtpPort.Value
var client *smtp.Client
if port == "465" {
client, err = srv.connectToSmtpServerUsingImplicitTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
// Set up the authentication
auth := smtp.PlainAuth("", auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value, srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value, srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value, srv.appConfigService.DbConfig.SmtpHost.Value,
) )
if err := client.Auth(auth); err != nil {
// Send the email return fmt.Errorf("failed to authenticate SMTP client: %w", err)
err = smtp.SendMail(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
auth,
srv.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail.Email},
[]byte(c.String()),
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
} }
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}
return nil
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(toEmail.Email); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
_, err = w.Write([]byte(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil return nil
} }

View File

@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
return "Test email"
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}

View File

@@ -352,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{ profileClaims := map[string]interface{}{
"given_name": user.FirstName, "given_name": user.FirstName,
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FirstName + " " + user.LastName, "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
} }

View File

@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/background-image`, formData); await this.api.put(`/application-configuration/background-image`, formData);
} }
async sendTestEmail() {
await this.api.post('/application-configuration/test-email');
}
async getVersionInformation() { async getVersionInformation() {
const response = ( const response = (
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest') await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')

View File

@@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpSkipCertVerify: boolean;
}; };
export type AppConfigRawResponse = { export type AppConfigRawResponse = {

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -14,7 +16,9 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled); let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
@@ -23,7 +27,8 @@
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword, smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom smtpFrom: appConfig.smtpFrom,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -31,7 +36,8 @@
smtpPort: z.number().min(1), smtpPort: z.number().min(1),
smtpUser: z.string().min(1), smtpUser: z.string().min(1),
smtpPassword: z.string().min(1), smtpPassword: z.string().min(1),
smtpFrom: z.string().email() smtpFrom: z.string().email(),
smtpSkipCertVerify: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -39,11 +45,10 @@
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return false; if (!data) return false;
isLoading = true;
await callback({ await callback({
...data, ...data,
emailEnabled: true emailEnabled: true
}).finally(() => (isLoading = false)); });
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
@@ -59,6 +64,17 @@
emailEnabled = true; emailEnabled = true;
} }
} }
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your Email address.'))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
)
.finally(() => (isSendingTestEmail = false));
}
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
@@ -68,13 +84,23 @@
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div> </div>
<div class="mt-5 flex justify-end gap-3"> <div class="mt-8 flex justify-end gap-3">
{#if emailEnabled} {#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button> <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button onclick={onSubmit} type="submit">Save</Button>
{:else} {:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button> <Button onclick={onEnable} type="submit">Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>