diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go index 51ffb587..7dee5602 100644 --- a/backend/internal/controller/webauthn_controller.go +++ b/backend/internal/controller/webauthn_controller.go @@ -57,7 +57,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) { } userID := c.GetString("userID") - credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request) + credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) if err != nil { _ = c.Error(err) return @@ -134,8 +134,10 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) { func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) { userID := c.GetString("userID") credentialID := c.Param("id") + clientIP := c.ClientIP() + userAgent := c.Request.UserAgent() - err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID) + err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go index 46b6a76a..c7b0505b 100644 --- a/backend/internal/model/audit_log.go +++ b/backend/internal/model/audit_log.go @@ -34,6 +34,8 @@ const ( AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION" AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION" + AuditLogEventPasskeyAdded AuditLogEvent = "PASSKEY_ADDED" + AuditLogEventPasskeyRemoved AuditLogEvent = "PASSKEY_REMOVED" ) // Scan and Value methods for GORM to handle the custom type diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index c19e3560..abb2a23b 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -34,7 +34,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, country, city, err := s.geoliteService.GetLocationByIP(ipAddress) if err != nil { // Log the error but don't interrupt the operation - slog.Warn("Failed to get IP location", "error", err) + slog.Warn("Failed to get IP location", slog.String("ip", ipAddress), slog.Any("error", err)) } auditLog := model.AuditLog{ @@ -201,8 +201,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s WithContext(ctx). Joins("User"). Model(&model.AuditLog{}). - Select("DISTINCT \"User\".id, \"User\".username"). - Where("\"User\".username IS NOT NULL") + Select(`DISTINCT "User".id, "User".username`). + Where(`"User".username IS NOT NULL`) type Result struct { ID string `gorm:"column:id"` @@ -210,7 +210,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s } var results []Result - if err := query.Find(&results).Error; err != nil { + err = query.Find(&results).Error + if err != nil { return nil, fmt.Errorf("failed to query user IDs: %w", err) } @@ -246,7 +247,8 @@ func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []st } var results []Result - if err := query.Find(&results).Error; err != nil { + err = query.Find(&results).Error + if err != nil { return nil, fmt.Errorf("failed to query client IDs: %w", err) } diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go index d32e149b..79aa2e5e 100644 --- a/backend/internal/service/webauthn_service.go +++ b/backend/internal/service/webauthn_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/hex" + "errors" "fmt" "net/http" "time" @@ -114,7 +116,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) }, nil } -func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) { +func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -173,6 +175,9 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err) } + auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.ID), "passkeyName": passkeyName} + s.auditLogService.Create(ctx, model.AuditLogEventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx) + err = tx.Commit().Error if err != nil { return model.WebauthnCredential{}, fmt.Errorf("failed to commit transaction: %w", err) @@ -288,16 +293,30 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([ return credentials, nil } -func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID, credentialID string) error { - err := s.db. +func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + credential := &model.WebauthnCredential{} + err := tx. WithContext(ctx). - Where("id = ? AND user_id = ?", credentialID, userID). - Delete(&model.WebauthnCredential{}). + Clauses(clause.Returning{}). + Delete(credential, "id = ? AND user_id = ?", credentialID, userID). Error if err != nil { return fmt.Errorf("failed to delete record: %w", err) } + auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name} + s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx) + + err = tx.Commit().Error + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil } @@ -353,7 +372,7 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context userID, ok := token.Subject() if !ok { - return "", fmt.Errorf("access token does not contain user ID") + return "", errors.New("access token does not contain user ID") } // Check if token is issued less than a minute ago diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 21e184be..02e3b77a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -331,6 +331,10 @@ "token_sign_in": "Token Sign In", "client_authorization": "Client Authorization", "new_client_authorization": "New Client Authorization", + "device_code_authorization": "Device Code Authorization", + "new_device_code_authorization": "New Device Code Authorization", + "passkey_added": "Passkey Added", + "passkey_removed": "Passkey Removed", "disable_animations": "Disable Animations", "turn_off_ui_animations": "Turn off animations throughout the UI.", "user_disabled": "Account Disabled", diff --git a/frontend/src/lib/utils/audit-log-translator.ts b/frontend/src/lib/utils/audit-log-translator.ts index d5e41798..50d6c35d 100644 --- a/frontend/src/lib/utils/audit-log-translator.ts +++ b/frontend/src/lib/utils/audit-log-translator.ts @@ -5,7 +5,11 @@ export const eventTypes: Record = { TOKEN_SIGN_IN: m.token_sign_in(), CLIENT_AUTHORIZATION: m.client_authorization(), NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(), - ACCOUNT_CREATED: m.account_created() + ACCOUNT_CREATED: m.account_created(), + DEVICE_CODE_AUTHORIZATION: m.device_code_authorization(), + NEW_DEVICE_CODE_AUTHORIZATION: m.new_device_code_authorization(), + PASSKEY_ADDED: m.passkey_added(), + PASSKEY_REMOVED: m.passkey_removed(), } /**