Files
summercms/modules/cabana/commands.go

217 lines
6.7 KiB
Go

package cabana
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// RuntimeCommands returns the operator commands for backend administrators.
func RuntimeCommands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{
{
Name: "admin:create",
Description: "Create an activated backend administrator",
Flags: []bonfire.Flag{
{Name: "email", Description: "Admin email"},
{Name: "password", Description: "Admin password (deprecated: visible in the process list and shell history; omit it to be prompted, or pipe it on stdin)"},
{Name: "login", Description: "Login; defaults to the lower-cased email"},
{Name: "superuser", Description: "Grant superuser", Bare: true},
{Name: "role", Description: "Role code"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminCreate(ctx, app, in, out)
},
},
{
Name: "admin:reset-password",
Description: "Reset a backend administrator password and revoke existing tokens",
Args: []bonfire.Arg{{
Name: "identifier",
Description: "Login or email",
Required: true,
}},
Flags: []bonfire.Flag{{
Name: "password",
Description: "New password (deprecated: visible in the process list and shell history; omit it to be prompted, or pipe it on stdin)",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminResetPassword(ctx, app, in, out)
},
},
}
}
func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
email := strings.ToLower(strings.TrimSpace(flagValue(in, "email")))
if email == "" || !strings.Contains(email, "@") {
return errors.New("cabana: a valid email is required")
}
password, err := resolvePassword(in, out)
if err != nil {
return err
}
login := strings.TrimSpace(flagValue(in, "login"))
if login == "" {
login = email
}
roleCode := strings.TrimSpace(flagValue(in, "role"))
superuser := flagValue(in, "superuser") == "true"
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
roleID, err := roleIDByCode(tx, roleCode)
if err != nil {
return err
}
var existing int64
// Check both fields against both values: a new login equal to an
// existing email (or the reverse) would make the login identifier
// ambiguous, so neither admin could rely on it.
if err := tx.Model(&BackendUser{}).Where("login IN (?, ?) OR lower(email) IN (?, ?)", login, email, strings.ToLower(login), email).Count(&existing).Error; err != nil {
return err
}
if existing > 0 {
return errors.New("cabana: admin already exists")
}
hash, err := bouncer.HashPassword(adminBcryptCost(app), password)
if err != nil {
return err
}
now := time.Now().UTC()
user := BackendUser{
Login: login,
Email: email,
Password: hash,
IsActivated: true,
IsSuperuser: superuser,
RoleID: roleID,
ActivatedAt: &now,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
out.Success(fmt.Sprintf("created admin %s <%s>", user.Login, user.Email))
return nil
})
})
}
func adminResetPassword(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
identifier := ""
if value, ok := in.Argument("identifier"); ok {
identifier = strings.TrimSpace(value)
}
if identifier == "" && len(in.Args()) > 0 {
identifier = strings.TrimSpace(in.Args()[0])
}
if identifier == "" {
return errors.New("cabana: an identifier is required")
}
password, err := resolvePassword(in, out)
if err != nil {
return err
}
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var users []BackendUser
err := tx.Where("login = ? OR lower(email) = ?", identifier, strings.ToLower(identifier)).Find(&users).Error
if err != nil {
return err
}
if len(users) == 0 {
return errors.New("cabana: admin not found")
}
if len(users) > 1 {
return errors.New("cabana: ambiguous admin")
}
hash, err := bouncer.HashPassword(adminBcryptCost(app), password)
if err != nil {
return err
}
cutoff := time.Now().UTC().Add(time.Second)
if err := tx.Model(&BackendUser{}).Where("id = ?", users[0].ID).Updates(map[string]any{
"password": hash,
"tokens_valid_after": cutoff,
}).Error; err != nil {
return err
}
out.Success(fmt.Sprintf("reset password for %s", users[0].Login))
return nil
})
})
}
// resolvePassword returns the password for admin:create and
// admin:reset-password without putting it on the command line. Without
// --password it is read through out.Secret: hidden on a terminal, one line from
// stdin otherwise, so scripts can pipe it. --password still works, because the
// documented usage and existing scripts pass it, but it is deprecated: the value
// is visible in the process list and the shell history.
func resolvePassword(in bonfire.Input, out bonfire.Output) (string, error) {
password := flagValue(in, "password")
if password != "" {
out.Warning("--password is deprecated: the value is visible in the process list and the shell history. Omit it to be prompted, or pipe the password on stdin.")
} else {
var err error
if password, err = out.Secret("Password"); err != nil {
return "", err
}
}
if strings.TrimSpace(password) == "" {
return "", errors.New("cabana: a password is required (enter it at the prompt or pipe it on stdin)")
}
return password, nil
}
func roleIDByCode(tx *gorm.DB, code string) (*uint, error) {
if code == "" {
return nil, nil
}
var ids []uint
if err := tx.Model(&BackendUserRole{}).Where("code = ?", code).Pluck("id", &ids).Error; err != nil {
return nil, err
}
switch len(ids) {
case 0:
return nil, fmt.Errorf("cabana: unknown role %q", code)
case 1:
return &ids[0], nil
default:
return nil, fmt.Errorf("cabana: ambiguous role %q", code)
}
}
func flagValue(in bonfire.Input, name string) string {
if in == nil {
return ""
}
value, _ := in.Flag(name)
return value
}
func withAdminDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error {
if app == nil {
return errors.New("cabana: app is nil")
}
if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return err
}
defer sqlDB.Close()
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
return err
}
return fn(gdb)
}