tenseleyflow/shithub / 40539d7

Browse files

Wire shithubd admin clear-2fa subcommand with audit + notification

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
40539d75279fffcfd8466f90e233618662f2ff1f
Parents
c3a7f12
Tree
343fc46

1 changed file

StatusFile+-
M cmd/shithubd/admin.go 85 0
cmd/shithubd/admin.gomodified
@@ -12,6 +12,7 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 	"github.com/spf13/cobra"
1414
 
15
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1516
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
1617
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
1718
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
@@ -122,6 +123,90 @@ func pickAdminEmailSender(cfg config.Config) (email.Sender, error) {
122123
 	}
123124
 }
124125
 
126
+var adminClear2FACmd = &cobra.Command{
127
+	Use:   "clear-2fa <username>",
128
+	Short: "Clear 2FA enrollment from a user account (support escape hatch)",
129
+	Long: `Removes the user's TOTP enrollment and recovery codes, writes an
130
+audit-log row, and emails the user a notification. Use only when the user
131
+has lost both their authenticator device and their recovery codes —
132
+typically after manual identity verification through a support channel.`,
133
+	Args: cobra.ExactArgs(1),
134
+	RunE: func(cmd *cobra.Command, args []string) error {
135
+		username := args[0]
136
+		cfg, err := config.Load(nil)
137
+		if err != nil {
138
+			return err
139
+		}
140
+		if cfg.DB.URL == "" {
141
+			return errors.New("admin clear-2fa: DB not configured (set SHITHUB_DATABASE_URL)")
142
+		}
143
+		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
144
+		defer cancel()
145
+
146
+		pool, err := db.Open(ctx, db.Config{
147
+			URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
148
+			ConnectTimeout: cfg.DB.ConnectTimeout,
149
+		})
150
+		if err != nil {
151
+			return fmt.Errorf("db open: %w", err)
152
+		}
153
+		defer pool.Close()
154
+
155
+		q := usersdb.New()
156
+		user, err := q.GetUserByUsername(ctx, pool, username)
157
+		if err != nil {
158
+			return fmt.Errorf("user %q not found", username)
159
+		}
160
+
161
+		tx, err := pool.Begin(ctx)
162
+		if err != nil {
163
+			return fmt.Errorf("begin: %w", err)
164
+		}
165
+		defer func() { _ = tx.Rollback(ctx) }()
166
+
167
+		if err := q.DeleteUserTOTP(ctx, tx, user.ID); err != nil {
168
+			return fmt.Errorf("delete totp: %w", err)
169
+		}
170
+		if err := q.DeleteUserRecoveryCodes(ctx, tx, user.ID); err != nil {
171
+			return fmt.Errorf("delete recovery: %w", err)
172
+		}
173
+		recorder := audit.NewRecorder()
174
+		if err := recorder.Record(ctx, tx, 0,
175
+			audit.ActionAdminCleared2FA, audit.TargetUser, user.ID,
176
+			map[string]any{"admin": "cli"}); err != nil {
177
+			return fmt.Errorf("audit: %w", err)
178
+		}
179
+		if err := tx.Commit(ctx); err != nil {
180
+			return fmt.Errorf("commit: %w", err)
181
+		}
182
+
183
+		// Best-effort notification email.
184
+		if user.PrimaryEmailID.Valid {
185
+			em, err := q.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
186
+			if err == nil {
187
+				sender, err := pickAdminEmailSender(cfg)
188
+				if err == nil {
189
+					msg, err := email.NoticeMessage(email.Branding{
190
+						SiteName: cfg.Auth.SiteName,
191
+						BaseURL:  cfg.Auth.BaseURL,
192
+						From:     cfg.Auth.EmailFrom,
193
+					}, string(em.Email), user.Username, "admin_cleared_2fa")
194
+					if err == nil {
195
+						if err := sender.Send(ctx, msg); err != nil {
196
+							_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warn: notification email failed: %v\n", err)
197
+						}
198
+					}
199
+				}
200
+			}
201
+		}
202
+
203
+		_, _ = fmt.Fprintf(cmd.OutOrStdout(),
204
+			"clear-2fa: 2FA + recovery codes cleared for %s; audit row written\n", user.Username)
205
+		return nil
206
+	},
207
+}
208
+
125209
 func init() {
126210
 	adminCmd.AddCommand(adminResetPasswordCmd)
211
+	adminCmd.AddCommand(adminClear2FACmd)
127212
 }