feat(breakglass): 恢复模式用邮件验证码证明管理员身份,发不出或验不过走带原因审计的 OVERRIDE (#13)
This commit is contained in:
8 files changed
+1071
-134
No files matched your search
+147
-28
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
@@ -14,9 +15,8 @@ import (
|
||||
)
|
||||
|
||||
type owAuthMsg struct {
|
||||
matched string
|
||||
ok bool
|
||||
err error
|
||||
start recoveryStart
|
||||
err error
|
||||
}
|
||||
|
||||
type owProvisionMsg struct {
|
||||
@@ -29,14 +29,15 @@ type owStep int
|
||||
const (
|
||||
owAuth owStep = iota
|
||||
owOverride
|
||||
owCode // typing the recovery code mailed to the named admin
|
||||
owProvision
|
||||
owWorking
|
||||
owDone
|
||||
owError
|
||||
)
|
||||
|
||||
// ownerModel collects the owner account. The input phases (admin auth, root
|
||||
// override, owner details) are huh forms; the async phases (verifying,
|
||||
// ownerModel collects the owner account. The input phases (admin name, recovery
|
||||
// code, root override, owner details) are huh forms; the async phases (verifying,
|
||||
// provisioning) show a spinner; the done phase shows the credential card. The
|
||||
// outward contract is unchanged: it emits an ownerResultMsg when finished.
|
||||
//
|
||||
@@ -55,6 +56,18 @@ type ownerModel struct {
|
||||
mode string // "bootstrap", "recovery", "root_override"
|
||||
accountable string
|
||||
attempt string
|
||||
recovery recoveryConfig
|
||||
|
||||
// Recovery proof: the admin the typed name resolved to, the code mailed to it,
|
||||
// and once settled either how it was proven or why the run fell back to the
|
||||
// override.
|
||||
admin *api.StaffUser
|
||||
code *recoveryCode
|
||||
codeNote string // "wrong code" line shown above a rebuilt code form
|
||||
verifiedBy string
|
||||
codeSentTo string
|
||||
skip string // otpSkip*
|
||||
skipDetail string
|
||||
|
||||
step owStep
|
||||
form *huh.Form
|
||||
@@ -66,6 +79,7 @@ type ownerModel struct {
|
||||
|
||||
// huh-bound form values
|
||||
authUser string
|
||||
codeInput string
|
||||
overrideTok string
|
||||
ownerUser string
|
||||
ownerEmail string
|
||||
@@ -124,6 +138,12 @@ func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *own
|
||||
return m
|
||||
}
|
||||
|
||||
// withRecovery hands the model the relay its recovery codes go through.
|
||||
func (m *ownerModel) withRecovery(r recoveryConfig) *ownerModel {
|
||||
m.recovery = r
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
|
||||
|
||||
// subject is the human label for the account being provisioned, branching every
|
||||
@@ -152,7 +172,7 @@ func (m *ownerModel) sized(f *huh.Form) *huh.Form {
|
||||
}
|
||||
|
||||
func (m *ownerModel) isFormStep() bool {
|
||||
return m.step == owAuth || m.step == owOverride || m.step == owProvision
|
||||
return m.step == owAuth || m.step == owOverride || m.step == owCode || m.step == owProvision
|
||||
}
|
||||
|
||||
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -161,16 +181,15 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
return m, m.failCmd(msg.err)
|
||||
}
|
||||
if msg.ok {
|
||||
m.mode = "recovery"
|
||||
m.accountable = msg.matched
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
m.admin = msg.start.admin
|
||||
if msg.start.code != nil {
|
||||
m.code = msg.start.code
|
||||
m.codeNote = ""
|
||||
m.step = owCode
|
||||
m.form = m.sized(m.buildCodeForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
m.step = owOverride
|
||||
m.form = m.sized(m.buildOverrideForm())
|
||||
return m, m.form.Init()
|
||||
return m.toOverride(msg.start.skip, msg.start.detail)
|
||||
|
||||
case owProvisionMsg:
|
||||
if msg.err != nil {
|
||||
@@ -221,7 +240,9 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
if m.step == owOverride {
|
||||
if m.step == owOverride || m.step == owCode {
|
||||
// Start over: a code mailed for the old attempt dies with it.
|
||||
m.admin, m.code, m.skip, m.skipDetail = nil, nil, "", ""
|
||||
m.step = owAuth
|
||||
m.form = m.sized(m.buildAuthForm())
|
||||
return m, m.form.Init()
|
||||
@@ -253,18 +274,41 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
||||
case owAuth:
|
||||
m.attempt = strings.TrimSpace(m.authUser)
|
||||
m.step = owWorking
|
||||
m.working = "Verifying admin…"
|
||||
user := m.authUser
|
||||
m.working = "Sending a recovery code…"
|
||||
user, rc, osUser, op := m.authUser, m.recovery, m.osUser, m.operation
|
||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||
matched, ok, err := authenticateAdmin(m.ctx, m.store, user)
|
||||
return owAuthMsg{matched: matched, ok: ok, err: err}
|
||||
start, err := beginRecovery(m.ctx, m.store, rc, user, osUser, op)
|
||||
return owAuthMsg{start: start, err: err}
|
||||
})
|
||||
case owCode:
|
||||
typed := strings.TrimSpace(m.codeInput)
|
||||
m.codeInput = ""
|
||||
if typed == breakGlassOverrideToken {
|
||||
m.skip, m.skipDetail = otpSkipByOperator, ""
|
||||
m.code = nil
|
||||
return m.proceedAsRoot()
|
||||
}
|
||||
switch m.code.check(typed, m.recovery.clock()) {
|
||||
case codeAccepted:
|
||||
m.mode = "recovery"
|
||||
m.accountable = m.admin.Username
|
||||
m.verifiedBy = verifiedByEmailOTP
|
||||
m.codeSentTo = m.admin.Email
|
||||
m.code = nil
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
case codeWrong:
|
||||
m.codeNote = fmt.Sprintf("That code is wrong. %d attempts left.", m.code.attemptsLeft())
|
||||
m.form = m.sized(m.buildCodeForm())
|
||||
return m, m.form.Init()
|
||||
case codeExpired:
|
||||
return m.toOverride(otpSkipCodeExpired, "")
|
||||
default:
|
||||
return m.toOverride(otpSkipCodeRejected, fmt.Sprintf("%d wrong codes", recoveryCodeAttempts))
|
||||
}
|
||||
case owOverride:
|
||||
m.mode = "root_override"
|
||||
m.accountable = m.osUser
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
return m.proceedAsRoot()
|
||||
case owProvision:
|
||||
m.username = strings.TrimSpace(m.ownerUser)
|
||||
m.step = owWorking
|
||||
@@ -274,6 +318,24 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// toOverride records why no code proved an admin and asks for the typed OVERRIDE.
|
||||
func (m *ownerModel) toOverride(skip, detail string) (tea.Model, tea.Cmd) {
|
||||
m.skip, m.skipDetail = skip, detail
|
||||
m.code = nil
|
||||
m.step = owOverride
|
||||
m.form = m.sized(m.buildOverrideForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
|
||||
// proceedAsRoot is the typed OVERRIDE: the run goes on as the OS user, unverified.
|
||||
func (m *ownerModel) proceedAsRoot() (tea.Model, tea.Cmd) {
|
||||
m.mode = "root_override"
|
||||
m.accountable = m.osUser
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
|
||||
func (m *ownerModel) provisionCmd() tea.Cmd {
|
||||
op := breakGlassOp{
|
||||
mode: m.mode,
|
||||
@@ -282,6 +344,10 @@ func (m *ownerModel) provisionCmd() tea.Cmd {
|
||||
ownerUsername: m.username,
|
||||
ownerEmail: m.ownerEmail,
|
||||
attemptedAdmin: m.attempt,
|
||||
verifiedBy: m.verifiedBy,
|
||||
codeSentTo: m.codeSentTo,
|
||||
otpSkipped: m.skip,
|
||||
otpSkipDetail: m.skipDetail,
|
||||
}
|
||||
// performAddOperator and performBreakGlass share a signature; the operation
|
||||
// discriminator selects which one runs. The operator path is insert-only and
|
||||
@@ -320,7 +386,7 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Admin authentication").
|
||||
Description("A staff account already exists. Identify yourself to continue."),
|
||||
Description("A staff account already exists. Name yours: a one-time code goes to its verified email address."),
|
||||
huh.NewInput().
|
||||
Title("Admin username").
|
||||
Value(&m.authUser).
|
||||
@@ -328,11 +394,64 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
|
||||
)))
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildCodeForm() *huh.Form {
|
||||
desc := fmt.Sprintf("A recovery code went to %s, the verified address of %q. It works for %d minutes.\n\n"+
|
||||
"No mail? Type %s to go on as OS user %q with root authority; the audit log records that as an unverified override. Esc starts over.",
|
||||
maskEmail(m.admin.Email), m.admin.Username, int(recoveryCodeTTL/time.Minute), breakGlassOverrideToken, m.osUser)
|
||||
if m.codeNote != "" {
|
||||
desc = m.codeNote + "\n\n" + desc
|
||||
}
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewNote().Title("Email verification").Description(desc),
|
||||
huh.NewInput().
|
||||
Title("Recovery code").
|
||||
Value(&m.codeInput).
|
||||
Validate(func(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == breakGlassOverrideToken || isRecoveryCodeShape(s) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("enter the 6-digit code, or " + breakGlassOverrideToken)
|
||||
}),
|
||||
)))
|
||||
}
|
||||
|
||||
func isRecoveryCodeShape(s string) bool {
|
||||
if len(s) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// overrideReason says why no code proved an admin, first line of the override form.
|
||||
func (m *ownerModel) overrideReason() string {
|
||||
switch m.skip {
|
||||
case otpSkipUnknownAdmin:
|
||||
return fmt.Sprintf("No staff account is named %q.", m.attempt)
|
||||
case otpSkipNoVerifiedEmail:
|
||||
return fmt.Sprintf("%q has no verified email address, so no recovery code can reach it.", m.admin.Username)
|
||||
case otpSkipNoRelay:
|
||||
return "No mail relay can send a recovery code: " + m.skipDetail + "."
|
||||
case otpSkipSendFailed:
|
||||
return "The recovery code could not be sent: " + m.skipDetail + "."
|
||||
case otpSkipCodeExpired:
|
||||
return "The recovery code expired."
|
||||
case otpSkipCodeRejected:
|
||||
return fmt.Sprintf("%d wrong codes; that code no longer works.", recoveryCodeAttempts)
|
||||
}
|
||||
return "No admin was verified."
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Root override").
|
||||
Description(fmt.Sprintf("That credential did not match. Proceed as OS user %q with root authority by typing the confirmation token.", m.osUser)),
|
||||
Description(m.overrideReason()+"\n\n"+fmt.Sprintf("Proceed as OS user %q with root authority by typing the confirmation token. The audit log records this run as an unverified root override and the reason above. Esc starts over.", m.osUser)),
|
||||
huh.NewInput().
|
||||
Title("Type "+breakGlassOverrideToken+" to confirm").
|
||||
Value(&m.overrideTok).
|
||||
@@ -352,14 +471,14 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
|
||||
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
|
||||
switch m.mode {
|
||||
case "recovery":
|
||||
desc = fmt.Sprintf("Authenticated as %q.", m.accountable)
|
||||
desc = fmt.Sprintf("Verified as %q by an email code.", m.accountable)
|
||||
case "root_override":
|
||||
desc = "Root override — the Owner will be reset."
|
||||
}
|
||||
if m.operation == bgAddOperator {
|
||||
switch m.mode {
|
||||
case "recovery":
|
||||
desc = fmt.Sprintf("Add an Operator — authenticated as %q.", m.accountable)
|
||||
desc = fmt.Sprintf("Add an Operator — verified as %q by an email code.", m.accountable)
|
||||
case "root_override":
|
||||
desc = "Add an Operator (root override)."
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user