feat(profile): align mfa payloads
This commit is contained in:
@@ -52,6 +52,21 @@ var criticalRoutes = []route{
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/actions/contact_merge", Controller: "api/v1/accounts/actions/contact_merges#create", Source: "routes.rb:55"},
|
||||
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/onboarding", Controller: "api/v1/accounts/onboardings#update", Source: "routes.rb:58"},
|
||||
|
||||
{Method: "GET", Path: "/api/v1/profile", Controller: "api/v1/profiles#show", Source: "routes.rb:421"},
|
||||
{Method: "PATCH", Path: "/api/v1/profile", Controller: "api/v1/profiles#update", Source: "routes.rb:421"},
|
||||
{Method: "PUT", Path: "/api/v1/profile", Controller: "api/v1/profiles#update", Source: "routes.rb:421"},
|
||||
{Method: "DELETE", Path: "/api/v1/profile/avatar", Controller: "api/v1/profiles#avatar", Source: "routes.rb:422"},
|
||||
{Method: "POST", Path: "/api/v1/profile/availability", Controller: "api/v1/profiles#availability", Source: "routes.rb:424"},
|
||||
{Method: "POST", Path: "/api/v1/profile/auto_offline", Controller: "api/v1/profiles#auto_offline", Source: "routes.rb:425"},
|
||||
{Method: "PUT", Path: "/api/v1/profile/set_active_account", Controller: "api/v1/profiles#set_active_account", Source: "routes.rb:426"},
|
||||
{Method: "POST", Path: "/api/v1/profile/resend_confirmation", Controller: "api/v1/profiles#resend_confirmation", Source: "routes.rb:427"},
|
||||
{Method: "POST", Path: "/api/v1/profile/reset_access_token", Controller: "api/v1/profiles#reset_access_token", Source: "routes.rb:428"},
|
||||
{Method: "GET", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#show", Source: "routes.rb:433"},
|
||||
{Method: "POST", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#create", Source: "routes.rb:433"},
|
||||
{Method: "DELETE", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#destroy", Source: "routes.rb:433"},
|
||||
{Method: "POST", Path: "/api/v1/profile/mfa/verify", Controller: "api/v1/profile/mfa#verify", Source: "routes.rb:434"},
|
||||
{Method: "POST", Path: "/api/v1/profile/mfa/backup_codes", Controller: "api/v1/profile/mfa#backup_codes", Source: "routes.rb:435"},
|
||||
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#index", Source: "routes.rb:59"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#create", Source: "routes.rb:59"},
|
||||
{Method: "PUT", Path: "/api/v1/accounts/:account_id/agents/:agent_id", Controller: "api/v1/accounts/agents#update", Source: "routes.rb:59"},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -111,6 +111,7 @@ DELETE /api/v1/notification_subscriptions/:identifier
|
||||
DELETE /api/v1/notifications/:id
|
||||
DELETE /api/v1/notifications/destroy_all
|
||||
DELETE /api/v1/profile/avatar
|
||||
DELETE /api/v1/profile/mfa
|
||||
DELETE /api/v1/profile/mfa/
|
||||
DELETE /api/v1/push_subscriptions/:id
|
||||
DELETE /api/v1/widget/labels/:label_id
|
||||
@@ -402,6 +403,7 @@ GET /api/v1/oidc/callback
|
||||
GET /api/v1/oidc/config
|
||||
GET /api/v1/oidc/discovery
|
||||
GET /api/v1/profile
|
||||
GET /api/v1/profile/mfa
|
||||
GET /api/v1/profile/mfa/
|
||||
GET /api/v1/push_subscriptions
|
||||
GET /api/v1/saml/login
|
||||
@@ -554,6 +556,7 @@ PATCH /api/v1/accounts/:account_id/teams/:team_id
|
||||
PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members
|
||||
PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members/
|
||||
PATCH /api/v1/accounts/:account_id/webhooks/:webhook_id
|
||||
PATCH /api/v1/profile
|
||||
PATCH /api/v1/widget/contact
|
||||
PATCH /api/v1/widget/contact/set_user
|
||||
PATCH /api/v1/widget/messages/:message_id
|
||||
@@ -803,6 +806,7 @@ POST /api/v1/notifications/:id/unread
|
||||
POST /api/v1/notifications/read_all
|
||||
POST /api/v1/profile/auto_offline
|
||||
POST /api/v1/profile/availability
|
||||
POST /api/v1/profile/mfa
|
||||
POST /api/v1/profile/mfa/
|
||||
POST /api/v1/profile/mfa/backup_codes
|
||||
POST /api/v1/profile/mfa/verify
|
||||
@@ -961,4 +965,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 963
|
||||
TOTAL: 967
|
||||
|
||||
@@ -7,7 +7,7 @@ Generated from:
|
||||
|
||||
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
|
||||
|
||||
Summary: 407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425 tracked critical routes.
|
||||
Summary: 421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -99,6 +99,8 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#destroy` | `routes.rb:299` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#destroy` | `routes.rb:342` | exact |
|
||||
| DELETE | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#destroy` | `routes.rb:440` | exact |
|
||||
| DELETE | `/api/v1/profile/avatar` | `/api/v1/profile/avatar` | `api/v1/profiles#avatar` | `routes.rb:422` | exact |
|
||||
| DELETE | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#destroy` | `routes.rb:433` | exact |
|
||||
| DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact |
|
||||
| GET | `/.well-known/apple-app-site-association` | `/.well-known/apple-app-site-association` | `apple_app#site_association` | `routes.rb:658` | exact |
|
||||
| GET | `/.well-known/assetlinks.json` | `/.well-known/assetlinks.json` | `android_app#assetlinks` | `routes.rb:657` | exact |
|
||||
@@ -215,6 +217,8 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#show` | `routes.rb:296` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#index` | `routes.rb:297` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#index` | `routes.rb:342` | exact |
|
||||
| GET | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#show` | `routes.rb:421` | exact |
|
||||
| GET | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#show` | `routes.rb:433` | exact |
|
||||
| GET | `/api/v1/widget/campaigns` | `/api/v1/widget/campaigns` | `api/v1/widget/campaigns#index` | `routes.rb:445` | exact |
|
||||
| GET | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#show` | `routes.rb:458` | exact |
|
||||
| GET | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#index` | `routes.rb:448` | exact |
|
||||
@@ -288,6 +292,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| PATCH | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#update` | `routes.rb:296` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#update` | `routes.rb:300` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact |
|
||||
| PATCH | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#update` | `routes.rb:421` | exact |
|
||||
| PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact |
|
||||
| POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact |
|
||||
@@ -395,6 +400,13 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp/authorization` | `/api/v1/accounts/:account_id/whatsapp/authorization` | `api/v1/accounts/whatsapp/authorizations#create` | `routes.rb:339` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `api/v1/accounts/whatsapp_calls#initiate` | `routes.rb:245` | exact |
|
||||
| POST | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#create` | `routes.rb:440` | exact |
|
||||
| POST | `/api/v1/profile/auto_offline` | `/api/v1/profile/auto_offline` | `api/v1/profiles#auto_offline` | `routes.rb:425` | exact |
|
||||
| POST | `/api/v1/profile/availability` | `/api/v1/profile/availability` | `api/v1/profiles#availability` | `routes.rb:424` | exact |
|
||||
| POST | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#create` | `routes.rb:433` | exact |
|
||||
| POST | `/api/v1/profile/mfa/backup_codes` | `/api/v1/profile/mfa/backup_codes` | `api/v1/profile/mfa#backup_codes` | `routes.rb:435` | exact |
|
||||
| POST | `/api/v1/profile/mfa/verify` | `/api/v1/profile/mfa/verify` | `api/v1/profile/mfa#verify` | `routes.rb:434` | exact |
|
||||
| POST | `/api/v1/profile/resend_confirmation` | `/api/v1/profile/resend_confirmation` | `api/v1/profiles#resend_confirmation` | `routes.rb:427` | exact |
|
||||
| POST | `/api/v1/profile/reset_access_token` | `/api/v1/profile/reset_access_token` | `api/v1/profiles#reset_access_token` | `routes.rb:428` | exact |
|
||||
| POST | `/api/v1/widget/config` | `/api/v1/widget/config` | `api/v1/widget/config#create` | `routes.rb:444` | exact |
|
||||
| POST | `/api/v1/widget/contact/destroy_custom_attributes` | `/api/v1/widget/contact/destroy_custom_attributes` | `api/v1/widget/contact#destroy_custom_attributes` | `routes.rb:460` | exact |
|
||||
| POST | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#create` | `routes.rb:448` | exact |
|
||||
@@ -454,6 +466,8 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| PUT | `/api/v1/accounts/:account_id/sla_policies/:id` | `/api/v1/accounts/:account_id/sla_policies/:id` | `api/v1/accounts/sla_policies#update` | `routes.rb:121` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#update` | `routes.rb:296` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact |
|
||||
| PUT | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#update` | `routes.rb:421` | exact |
|
||||
| PUT | `/api/v1/profile/set_active_account` | `/api/v1/profile/set_active_account` | `api/v1/profiles#set_active_account` | `routes.rb:426` | exact |
|
||||
| PUT | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#update` | `routes.rb:458` | exact |
|
||||
| PUT | `/api/v1/widget/messages/:message_id` | `/api/v1/widget/messages/:message_id` | `api/v1/widget/messages#update` | `routes.rb:447` | exact |
|
||||
| PUT | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#update` | `routes.rb:585` | exact |
|
||||
|
||||
+154
-3
@@ -6,15 +6,19 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mfaBackupCodesAttribute = "mfa_backup_code_hashes"
|
||||
|
||||
// Reference: P2E §1.5 — MFA (TOTP) support
|
||||
// Implements time-based one-time password (TOTP) per RFC 6238.
|
||||
// Corresponds to Chatwoot enterprise TwoFactorAuthController pattern.
|
||||
@@ -99,6 +103,46 @@ func (s *MFAService) EnableTOTP(userID uint, secret string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginTOTPSetup creates and stores a pending TOTP secret for Chatwoot's
|
||||
// profile MFA setup flow. The user is activated only after VerifyAndActivateTOTP.
|
||||
func (s *MFAService) BeginTOTPSetup(userID uint) (string, string, error) {
|
||||
secret, uri, err := s.GenerateTOTPSecret(userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return "", "", fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
user.TOTPSecret = secret
|
||||
user.TOTPEnabled = false
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return "", "", fmt.Errorf("failed to store pending totp secret: %w", err)
|
||||
}
|
||||
return secret, uri, nil
|
||||
}
|
||||
|
||||
// VerifyAndActivateTOTP validates the pending profile MFA code, enables MFA,
|
||||
// and returns the one-time backup codes expected by Chatwoot's verify response.
|
||||
func (s *MFAService) VerifyAndActivateTOTP(userID uint, code string) ([]string, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
if user.TOTPSecret == "" {
|
||||
return nil, fmt.Errorf("mfa setup not started for user")
|
||||
}
|
||||
if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) {
|
||||
return nil, fmt.Errorf("invalid totp code")
|
||||
}
|
||||
user.TOTPEnabled = true
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to enable totp: %w", err)
|
||||
}
|
||||
return s.GenerateBackupCodes(userID)
|
||||
}
|
||||
|
||||
// VerifyTOTPCode validates a TOTP code against the user's stored secret.
|
||||
// Uses a 1-period window (±30 seconds) to account for clock drift per RFC 6238.
|
||||
func (s *MFAService) VerifyTOTPCode(userID uint, code string) (bool, error) {
|
||||
@@ -142,6 +186,35 @@ func (s *MFAService) DisableTOTP(userID uint, code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableTOTPWithPassword mirrors Chatwoot profile MFA destroy: the current
|
||||
// password and either an OTP code or a backup code must be provided.
|
||||
func (s *MFAService) DisableTOTPWithPassword(userID uint, password, code, backupCode string) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
if !user.TOTPEnabled || user.TOTPSecret == "" {
|
||||
return fmt.Errorf("mfa not enabled for user")
|
||||
}
|
||||
if !pkgcrypto.CheckPassword(password, user.PasswordDigest) && !pkgcrypto.CheckPassword(password, user.Password) {
|
||||
return fmt.Errorf("invalid credentials")
|
||||
}
|
||||
if backupCode != "" {
|
||||
if err := s.consumeBackupCode(&user, backupCode); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) {
|
||||
return fmt.Errorf("invalid totp code")
|
||||
}
|
||||
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPEnabled = false
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to disable totp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsMFAEnabled checks whether MFA is enabled for a user.
|
||||
func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) {
|
||||
var user model.User
|
||||
@@ -151,6 +224,16 @@ func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) {
|
||||
return user.TOTPEnabled, nil
|
||||
}
|
||||
|
||||
// BackupCodesGenerated reports whether the user currently has MFA backup codes.
|
||||
func (s *MFAService) BackupCodesGenerated(userID uint) (bool, error) {
|
||||
var user model.User
|
||||
if err := s.db.Select("custom_attributes").First(&user, userID).Error; err != nil {
|
||||
return false, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
codes := backupCodeHashes(user.CustomAttributes)
|
||||
return len(codes) > 0, nil
|
||||
}
|
||||
|
||||
// validateTOTP validates a TOTP code against a secret using the given config.
|
||||
// Allows ±1 period window for clock drift tolerance.
|
||||
func validateTOTP(secret string, code string, cfg TOTPConfig) bool {
|
||||
@@ -192,7 +275,7 @@ func generateTOTP(secret string, timeCounter int64, cfg TOTPConfig) string {
|
||||
truncated := (int32(hash[offset]&0x7f) << 24) |
|
||||
(int32(hash[offset+1]&0xff) << 16) |
|
||||
(int32(hash[offset+2]&0xff) << 8) |
|
||||
(int32(hash[offset+3]&0xff))
|
||||
(int32(hash[offset+3] & 0xff))
|
||||
|
||||
// Modulo 10^digits
|
||||
mod := int32(math.Pow10(cfg.Digits))
|
||||
@@ -217,15 +300,83 @@ func init() {
|
||||
// GenerateBackupCodes creates a set of one-time backup codes for MFA recovery.
|
||||
// Reference: Chatwoot MfaController#backup_codes
|
||||
func (s *MFAService) GenerateBackupCodes(userID uint) ([]string, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
var codes []string
|
||||
var hashes []string
|
||||
for i := 0; i < 10; i++ {
|
||||
code := cryptoRandomString(8)
|
||||
codes = append(codes, code)
|
||||
hash, err := pkgcrypto.HashPassword(code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash backup code: %w", err)
|
||||
}
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
attrs := customAttributesMap(user.CustomAttributes)
|
||||
attrs[mfaBackupCodesAttribute] = hashes
|
||||
encoded, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode backup codes: %w", err)
|
||||
}
|
||||
user.CustomAttributes = encoded
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to store backup codes: %w", err)
|
||||
}
|
||||
// TODO: store hashed backup codes in DB for later verification
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (s *MFAService) consumeBackupCode(user *model.User, code string) error {
|
||||
hashes := backupCodeHashes(user.CustomAttributes)
|
||||
for i, hash := range hashes {
|
||||
if pkgcrypto.CheckPassword(code, hash) {
|
||||
hashes = append(hashes[:i], hashes[i+1:]...)
|
||||
attrs := customAttributesMap(user.CustomAttributes)
|
||||
attrs[mfaBackupCodesAttribute] = hashes
|
||||
encoded, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode backup codes: %w", err)
|
||||
}
|
||||
user.CustomAttributes = encoded
|
||||
if err := s.db.Save(user).Error; err != nil {
|
||||
return fmt.Errorf("failed to consume backup code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid backup code")
|
||||
}
|
||||
|
||||
func backupCodeHashes(raw []byte) []string {
|
||||
attrs := customAttributesMap(raw)
|
||||
value, ok := attrs[mfaBackupCodesAttribute]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
hashes := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if text, ok := item.(string); ok && text != "" {
|
||||
hashes = append(hashes, text)
|
||||
}
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
func customAttributesMap(raw []byte) map[string]any {
|
||||
attrs := map[string]any{}
|
||||
if len(raw) == 0 {
|
||||
return attrs
|
||||
}
|
||||
_ = json.Unmarshal(raw, &attrs)
|
||||
return attrs
|
||||
}
|
||||
|
||||
// cryptoRandomString generates a random alphanumeric string of given length.
|
||||
func cryptoRandomString(length int) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
@@ -236,4 +387,4 @@ func cryptoRandomString(length int) string {
|
||||
b[i] = charset[int(buf[0])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,17 @@ type DisableMFARequest struct {
|
||||
TOTPCode string `json:"totp_code" binding:"required"` // current TOTP code for verification
|
||||
}
|
||||
|
||||
type profileMFAVerifyRequest struct {
|
||||
OTPCode string `json:"otp_code"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
type profileMFADisableRequest struct {
|
||||
Password string `json:"password"`
|
||||
OTPCode string `json:"otp_code"`
|
||||
BackupCode string `json:"backup_code"`
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// EnableMFA initiates MFA setup: generates a TOTP secret and QR URI.
|
||||
@@ -187,6 +198,117 @@ func RegisterMFARoutes(rg *gin.RouterGroup, handler *MFAHandler) {
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileMFAStatus matches Chatwoot Profile::MfaController#show.
|
||||
func (h *MFAHandler) ProfileMFAStatus(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
enabled, err := h.mfaService.IsMFAEnabled(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
backupCodesGenerated, err := h.mfaService.BackupCodesGenerated(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"feature_available": true,
|
||||
"enabled": enabled,
|
||||
"backup_codes_generated": backupCodesGenerated,
|
||||
})
|
||||
}
|
||||
|
||||
// ProfileEnableMFA matches Chatwoot Profile::MfaController#create.
|
||||
func (h *MFAHandler) ProfileEnableMFA(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
enabled, err := h.mfaService.IsMFAEnabled(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if enabled {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "MFA is already enabled"})
|
||||
return
|
||||
}
|
||||
secret, uri, err := h.mfaService.BeginTOTPSetup(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"provisioning_url": uri, "secret": secret})
|
||||
}
|
||||
|
||||
// ProfileVerifyMFA matches Chatwoot Profile::MfaController#verify.
|
||||
func (h *MFAHandler) ProfileVerifyMFA(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
var req profileMFAVerifyRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
code := req.OTPCode
|
||||
if code == "" {
|
||||
code = req.TOTPCode
|
||||
}
|
||||
backupCodes, err := h.mfaService.VerifyAndActivateTOTP(userID, code)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": true, "backup_codes": backupCodes})
|
||||
}
|
||||
|
||||
// ProfileDisableMFA matches Chatwoot Profile::MfaController#destroy.
|
||||
func (h *MFAHandler) ProfileDisableMFA(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
var req profileMFADisableRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.mfaService.DisableTOTPWithPassword(userID, req.Password, req.OTPCode, req.BackupCode); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false})
|
||||
}
|
||||
|
||||
// ProfileBackupCodes matches Chatwoot Profile::MfaController#backup_codes.
|
||||
func (h *MFAHandler) ProfileBackupCodes(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
var req profileMFAVerifyRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
code := req.OTPCode
|
||||
if code == "" {
|
||||
code = req.TOTPCode
|
||||
}
|
||||
valid, err := h.mfaService.VerifyTOTPCode(userID, code)
|
||||
if err != nil || !valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid totp code"})
|
||||
return
|
||||
}
|
||||
codes, err := h.mfaService.GenerateBackupCodes(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"backup_codes": codes})
|
||||
}
|
||||
|
||||
// BackupCodes generates one-time MFA backup codes.
|
||||
// POST /api/v1/profile/mfa/backup_codes or /api/v1/auth/mfa/backup_codes
|
||||
// Reference: Chatwoot MfaController#backup_codes
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -107,6 +108,11 @@ func (s *MFAHandlerTestSuite) setupRouter(userID uint) {
|
||||
mfaGroup.POST("/verify", s.handler.VerifyMFA)
|
||||
mfaGroup.POST("/disable", s.handler.DisableMFA)
|
||||
}
|
||||
r.GET("/api/v1/profile/mfa", s.handler.ProfileMFAStatus)
|
||||
r.POST("/api/v1/profile/mfa", s.handler.ProfileEnableMFA)
|
||||
r.DELETE("/api/v1/profile/mfa", s.handler.ProfileDisableMFA)
|
||||
r.POST("/api/v1/profile/mfa/verify", s.handler.ProfileVerifyMFA)
|
||||
r.POST("/api/v1/profile/mfa/backup_codes", s.handler.ProfileBackupCodes)
|
||||
|
||||
s.router = r
|
||||
}
|
||||
@@ -213,7 +219,7 @@ func generateTOTPFromKey(key []byte, timeCounter int64, cfg auth.TOTPConfig) str
|
||||
truncated := (int32(hash[offset]&0x7f) << 24) |
|
||||
(int32(hash[offset+1]&0xff) << 16) |
|
||||
(int32(hash[offset+2]&0xff) << 8) |
|
||||
(int32(hash[offset+3]&0xff))
|
||||
(int32(hash[offset+3] & 0xff))
|
||||
|
||||
// Modulo 10^digits
|
||||
mod := int32(math.Pow10(cfg.Digits))
|
||||
@@ -660,5 +666,58 @@ func (s *MFAHandlerTestSuite) TestStatus_Unauthorized_NoUserID() {
|
||||
s.Equal(response.ErrUnauthorized, respStruct.Error.Code)
|
||||
}
|
||||
|
||||
func (s *MFAHandlerTestSuite) TestProfileMFA_StatusUsesChatwootRawPayload() {
|
||||
w := s.doRequest(http.MethodGet, "/api/v1/profile/mfa", "")
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var payload map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
s.Equal(true, payload["feature_available"])
|
||||
s.Equal(false, payload["enabled"])
|
||||
s.Equal(false, payload["backup_codes_generated"])
|
||||
s.NotContains(payload, "data")
|
||||
}
|
||||
|
||||
func (s *MFAHandlerTestSuite) TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads() {
|
||||
passwordHash, err := pkgcrypto.HashPassword("current-password")
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
|
||||
"password": passwordHash,
|
||||
"password_digest": passwordHash,
|
||||
}).Error)
|
||||
|
||||
enableRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa", "")
|
||||
s.Equal(http.StatusOK, enableRec.Code)
|
||||
var enablePayload map[string]string
|
||||
s.Require().NoError(json.Unmarshal(enableRec.Body.Bytes(), &enablePayload))
|
||||
s.NotEmpty(enablePayload["secret"])
|
||||
s.Contains(enablePayload["provisioning_url"], "otpauth://totp/")
|
||||
|
||||
code := s.generateValidTOTPCode(enablePayload["secret"])
|
||||
verifyRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/verify", jsonBody(map[string]interface{}{"otp_code": code}))
|
||||
s.Equal(http.StatusOK, verifyRec.Code)
|
||||
var verifyPayload struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BackupCodes []string `json:"backup_codes"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(verifyRec.Body.Bytes(), &verifyPayload))
|
||||
s.True(verifyPayload.Enabled)
|
||||
s.Len(verifyPayload.BackupCodes, 10)
|
||||
|
||||
backupRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/backup_codes", jsonBody(map[string]interface{}{"otp_code": code}))
|
||||
s.Equal(http.StatusOK, backupRec.Code)
|
||||
var backupPayload struct {
|
||||
BackupCodes []string `json:"backup_codes"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(backupRec.Body.Bytes(), &backupPayload))
|
||||
s.Len(backupPayload.BackupCodes, 10)
|
||||
|
||||
disableRec := s.doRequest(http.MethodDelete, "/api/v1/profile/mfa", jsonBody(map[string]interface{}{"password": "current-password", "otp_code": code}))
|
||||
s.Equal(http.StatusOK, disableRec.Code)
|
||||
var disablePayload map[string]bool
|
||||
s.Require().NoError(json.Unmarshal(disableRec.Body.Bytes(), &disablePayload))
|
||||
s.False(disablePayload["enabled"])
|
||||
}
|
||||
|
||||
// Ensure unused import warning doesn't cause issues
|
||||
var _ = assert.Equal
|
||||
var _ = assert.Equal
|
||||
|
||||
@@ -569,6 +569,7 @@ func RegisterRoutes(
|
||||
func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Profile routes — not scoped to account
|
||||
g.GET("/profile", h.Profile.Get)
|
||||
g.PATCH("/profile", h.Profile.Update)
|
||||
g.PUT("/profile", h.Profile.Update)
|
||||
g.PUT("/profile/avatar", h.Profile.UpdateAvatar)
|
||||
g.DELETE("/profile/avatar", h.Profile.DeleteAvatar)
|
||||
@@ -581,13 +582,16 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// MFA routes under profile scope (Chatwoot: scope module: 'profile' do resource :mfa)
|
||||
// GET /profile/mfa — show status, POST /profile/mfa — create (enable), DELETE /profile/mfa — destroy (disable)
|
||||
// POST /profile/mfa/verify — verify TOTP code, POST /profile/mfa/backup_codes — generate backup codes
|
||||
g.GET("/profile/mfa", h.MFA.ProfileMFAStatus)
|
||||
g.POST("/profile/mfa", h.MFA.ProfileEnableMFA)
|
||||
g.DELETE("/profile/mfa", h.MFA.ProfileDisableMFA)
|
||||
profileMfa := g.Group("/profile/mfa")
|
||||
{
|
||||
profileMfa.GET("/", h.MFA.MFAStatus)
|
||||
profileMfa.POST("/", h.MFA.EnableMFA)
|
||||
profileMfa.DELETE("/", h.MFA.DisableMFA)
|
||||
profileMfa.POST("/verify", h.MFA.VerifyMFA)
|
||||
profileMfa.POST("/backup_codes", h.MFA.BackupCodes)
|
||||
profileMfa.GET("/", h.MFA.ProfileMFAStatus)
|
||||
profileMfa.POST("/", h.MFA.ProfileEnableMFA)
|
||||
profileMfa.DELETE("/", h.MFA.ProfileDisableMFA)
|
||||
profileMfa.POST("/verify", h.MFA.ProfileVerifyMFA)
|
||||
profileMfa.POST("/backup_codes", h.MFA.ProfileBackupCodes)
|
||||
}
|
||||
|
||||
// Notification routes — user-scoped, not account-scoped
|
||||
|
||||
Reference in New Issue
Block a user