Go · 6403 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "time"
11
12 "github.com/jackc/pgx/v5/pgtype"
13 "github.com/spf13/cobra"
14
15 "github.com/tenseleyFlow/shithub/internal/auth/audit"
16 "github.com/tenseleyFlow/shithub/internal/auth/email"
17 "github.com/tenseleyFlow/shithub/internal/auth/token"
18 "github.com/tenseleyFlow/shithub/internal/infra/config"
19 "github.com/tenseleyFlow/shithub/internal/infra/db"
20 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21 )
22
23 var adminCmd = &cobra.Command{
24 Use: "admin",
25 Short: "Site-admin tooling (operator escape hatches)",
26 }
27
28 var adminResetPasswordCmd = &cobra.Command{
29 Use: "reset-password <username>",
30 Short: "Issue a password-reset link to the user's primary email",
31 Long: `Generates a fresh password-reset token (1-hour TTL) and sends it
32 to the user's primary email via the configured email backend. Useful when
33 a locked-out user can't drive the public reset flow themselves.
34
35 Exits non-zero if the user is unknown, has no primary email, or if the
36 email send fails.`,
37 Args: cobra.ExactArgs(1),
38 RunE: func(cmd *cobra.Command, args []string) error {
39 username := args[0]
40 cfg, err := config.Load(nil)
41 if err != nil {
42 return err
43 }
44 if cfg.DB.URL == "" {
45 return errors.New("admin reset-password: DB not configured (set SHITHUB_DATABASE_URL)")
46 }
47 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
48 defer cancel()
49
50 pool, err := db.Open(ctx, db.Config{
51 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
52 ConnectTimeout: cfg.DB.ConnectTimeout,
53 })
54 if err != nil {
55 return fmt.Errorf("db open: %w", err)
56 }
57 defer pool.Close()
58
59 q := usersdb.New()
60 user, err := q.GetUserByUsername(ctx, pool, username)
61 if err != nil {
62 return fmt.Errorf("user %q not found", username)
63 }
64 if !user.PrimaryEmailID.Valid {
65 return fmt.Errorf("user %q has no primary email on file", username)
66 }
67 em, err := q.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
68 if err != nil {
69 return fmt.Errorf("primary email lookup: %w", err)
70 }
71
72 tokEnc, tokHash, err := token.New()
73 if err != nil {
74 return fmt.Errorf("token: %w", err)
75 }
76 expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
77 if _, err := q.CreatePasswordReset(ctx, pool, usersdb.CreatePasswordResetParams{
78 UserID: user.ID, TokenHash: tokHash, ExpiresAt: expires,
79 }); err != nil {
80 return fmt.Errorf("create reset row: %w", err)
81 }
82
83 sender, err := pickAdminEmailSender(cfg)
84 if err != nil {
85 return err
86 }
87 msg, err := email.ResetMessage(email.Branding{
88 SiteName: cfg.Auth.SiteName,
89 BaseURL: cfg.Auth.BaseURL,
90 From: cfg.Auth.EmailFrom,
91 }, string(em.Email), tokEnc)
92 if err != nil {
93 return fmt.Errorf("build reset email: %w", err)
94 }
95 if err := sender.Send(ctx, msg); err != nil {
96 return fmt.Errorf("send reset email: %w", err)
97 }
98
99 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "reset-password: link emailed to %s (token expires %s)\n",
100 em.Email, expires.Time.Format(time.RFC3339))
101 return nil
102 },
103 }
104
105 func pickAdminEmailSender(cfg config.Config) (email.Sender, error) {
106 switch cfg.Auth.EmailBackend {
107 case "stdout":
108 return email.NewStdoutSender(os.Stdout), nil
109 case "smtp":
110 return &email.SMTPSender{
111 Addr: cfg.Auth.SMTP.Addr,
112 From: cfg.Auth.EmailFrom,
113 Username: cfg.Auth.SMTP.Username,
114 Password: cfg.Auth.SMTP.Password,
115 }, nil
116 case "postmark":
117 return &email.PostmarkSender{
118 ServerToken: cfg.Auth.Postmark.ServerToken,
119 From: cfg.Auth.EmailFrom,
120 }, nil
121 default:
122 return nil, fmt.Errorf("admin: unknown email_backend %q", cfg.Auth.EmailBackend)
123 }
124 }
125
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
209 func init() {
210 adminCmd.AddCommand(adminResetPasswordCmd)
211 adminCmd.AddCommand(adminClear2FACmd)
212 }
213