feat: add audit log with email notification (#26)

This commit is contained in:
Elias Schneider
2024-09-09 10:29:41 +02:00
committed by GitHub
parent 4010ee27d6
commit 9121239dd7
51 changed files with 944 additions and 163 deletions

View File

@@ -53,9 +53,34 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
Value: "svg",
},
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
Value: "false",
},
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
}
func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin()
@@ -67,19 +92,19 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpd
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
var applicationConfigurationVariable model.AppConfigVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
applicationConfigurationVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
}
tx.Commit()
@@ -101,7 +126,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
return s.loadDbConfigFromDb()
}
func (s *AppConfigService) ListApplicationConfiguration(showAll bool) ([]model.AppConfigVariable, error) {
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable
var err error

View File

@@ -0,0 +1,85 @@
package service
import (
userAgentParser "github.com/mileusna/useragent"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
)
type AuditLogService struct {
db *gorm.DB
appConfigService *AppConfigService
emailService *EmailService
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
UserAgent: userAgent,
UserID: userID,
Data: data,
}
// Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil {
log.Printf("Failed to create audit log: %v\n", err)
return model.AuditLog{}
}
return auditLog
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
// Count the number of times the user has logged in from the same device
var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
title := "New device login with " + s.appConfigService.DbConfig.AppName.Value
err := s.emailService.Send(user.Email, title, "login-with-new-device", map[string]interface{}{
"ipAddress": ipAddress,
"device": s.DeviceStringFromUserAgent(userAgent),
"dateTimeString": createdAuditLog.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
})
if err != nil {
log.Printf("Failed to send email: %v\n", err)
}
}()
}
return createdAuditLog
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
pagination, err := utils.Paginate(page, pageSize, query, &logs)
return logs, pagination, err
}
func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}

View File

@@ -0,0 +1,67 @@
package service
import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"net/smtp"
"os"
"strings"
)
type EmailService struct {
appConfigService *AppConfigService
}
func NewEmailService(appConfigService *AppConfigService) *EmailService {
return &EmailService{
appConfigService: appConfigService}
}
// Send sends an email notification
func (s *EmailService) Send(toEmail, title, templateName string, templateParameters map[string]interface{}) error {
// Check if SMTP settings are set
if s.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
// Construct the email message
subject := fmt.Sprintf("Subject: %s\n", title)
subject += "From: " + s.appConfigService.DbConfig.SmtpFrom.Value + "\n"
subject += "To: " + toEmail + "\n"
subject += "Content-Type: text/html; charset=UTF-8\n"
body, err := os.ReadFile(fmt.Sprintf("./email-templates/%s.html", templateName))
bodyString := string(body)
if err != nil {
return fmt.Errorf("failed to read email template: %w", err)
}
// Replace template parameters
templateParameters["appName"] = s.appConfigService.DbConfig.AppName.Value
templateParameters["appUrl"] = common.EnvConfig.AppURL
for key, value := range templateParameters {
bodyString = strings.ReplaceAll(bodyString, fmt.Sprintf("{{%s}}", key), fmt.Sprintf("%v", value))
}
emailBody := []byte(subject + bodyString)
// Set up the authentication information.
auth := smtp.PlainAuth("", s.appConfigService.DbConfig.SmtpUser.Value, s.appConfigService.DbConfig.SmtpPassword.Value, s.appConfigService.DbConfig.SmtpHost.Value)
// Send the email
err = smtp.SendMail(
s.appConfigService.DbConfig.SmtpHost.Value+":"+s.appConfigService.DbConfig.SmtpPort.Value,
auth,
s.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail},
emailBody,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}

View File

@@ -17,18 +17,22 @@ import (
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
}
func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService {
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
return &OidcService{
db: db,
jwtService: jwtService,
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
}
}
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
@@ -42,10 +46,16 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err
if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
return code, callbackURL, nil
}
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err
@@ -71,7 +81,13 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err
if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
return code, callbackURL, nil
}
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {

View File

@@ -12,11 +12,13 @@ import (
)
type WebAuthnService struct {
db *gorm.DB
webAuthn *webauthn.WebAuthn
db *gorm.DB
webAuthn *webauthn.WebAuthn
jwtService *JwtService
auditLogService *AuditLogService
}
func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAuthnService {
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
@@ -36,7 +38,7 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
}
wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa}
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService}
}
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
@@ -129,10 +131,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil
}
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (model.User, error) {
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, err
return model.User{}, "", err
}
session := webauthn.SessionData{
@@ -149,14 +151,21 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData)
if err != nil {
return model.User{}, err
return model.User{}, "", err
}
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return model.User{}, err
return model.User{}, "", err
}
return *user, nil
token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
return *user, token, nil
}
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {