Files
cmroubao/backend-api/cmd/authctl/main.go
T

267 lines
6.1 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/password"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
)
const passwordEnvironment = "CMROUBAO_AUTH_PASSWORD"
func main() {
if err := run(
os.Args[1:],
os.LookupEnv,
os.Stdout,
); err != nil {
log.Printf("auth command failed: %v", err)
os.Exit(1)
}
}
func run(
arguments []string,
lookup config.LookupEnvironment,
output io.Writer,
) error {
command, value, role, err := parseArguments(arguments)
if err != nil {
return err
}
databasePath, err := config.LoadDatabasePath(lookup)
if err != nil {
return err
}
var plainPassword string
if command == "create-user" || command == "reset-password" {
var exists bool
plainPassword, exists = lookup(passwordEnvironment)
if !exists || plainPassword == "" {
return errors.New(
passwordEnvironment + " must be set for " + command,
)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db, err := database.Open(ctx, databasePath)
if err != nil {
return errors.New("database startup failed")
}
defer func() {
if err := db.Close(); err != nil {
log.Print("database close failed")
}
}()
runner, err := migration.New(db)
if err != nil {
return errors.New("migration setup failed")
}
if err := requireCurrentMigrations(ctx, runner); err != nil {
return err
}
store, err := repository.New(db)
if err != nil {
return errors.New("auth repository setup failed")
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return errors.New("password manager setup failed")
}
service, err := usecase.NewAuthService(
store,
passwords,
usecase.SystemClock{},
usecase.UUIDGenerator{},
usecase.CryptoTokenGenerator{},
)
if err != nil {
return errors.New("auth service setup failed")
}
switch command {
case "create-user":
user, err := service.ProvisionUser(
ctx,
usecase.ProvisionUserCommand{
Username: value,
Password: plainPassword,
Role: role,
Active: true,
},
)
if err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"user_id=%s username=%s role=%s\n",
user.ID,
user.Username,
user.Role,
)
return err
case "create-device":
result, err := service.ProvisionDevice(
ctx,
usecase.ProvisionDeviceCommand{
Name: value,
Enabled: true,
},
)
if err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"device_id=%s\ndevice_token=%s\n",
result.Device.ID,
result.DeviceToken,
)
return err
case "reset-password":
if err := service.ResetUserPassword(
ctx,
usecase.ResetUserPasswordCommand{
Username: value,
Password: plainPassword,
},
); err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"username=%s password_updated=true\n",
domain.NormalizeUsername(value),
)
return err
case "enable-user", "disable-user":
active := command == "enable-user"
if err := service.SetUserActive(
ctx,
usecase.SetUserActiveCommand{
Username: value,
Active: active,
},
); err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"username=%s active=%t\n",
domain.NormalizeUsername(value),
active,
)
return err
case "enable-device", "disable-device":
enabled := command == "enable-device"
if err := service.SetDeviceEnabled(
ctx,
usecase.SetDeviceEnabledCommand{
DeviceID: value,
Enabled: enabled,
},
); err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"device_id=%s enabled=%t\n",
strings.TrimSpace(value),
enabled,
)
return err
default:
return errors.New("unsupported auth command")
}
}
func parseArguments(
arguments []string,
) (string, string, domain.UserRole, error) {
if len(arguments) == 2 && arguments[0] == "create-device" {
return "create-device", arguments[1], "", nil
}
if len(arguments) == 2 {
switch arguments[0] {
case "reset-password", "enable-user", "disable-user",
"enable-device", "disable-device":
return arguments[0], arguments[1], "", nil
}
}
if len(arguments) == 3 && arguments[0] == "create-user" {
role := domain.UserRole(strings.ToUpper(arguments[1]))
if role != domain.UserRoleAdmin && role != domain.UserRoleBuyer {
return "", "", "", errors.New(
"role must be ADMIN or BUYER",
)
}
return "create-user", arguments[2], role, nil
}
return "", "", "", errors.New(
"usage: authctl create-user <ADMIN|BUYER> <username> | " +
"authctl reset-password <username> | " +
"authctl create-device <name> | " +
"authctl <enable-user|disable-user> <username> | " +
"authctl <enable-device|disable-device> <device-id>",
)
}
type migrationStatusReader interface {
Status(context.Context) ([]migration.Status, error)
}
func requireCurrentMigrations(
ctx context.Context,
reader migrationStatusReader,
) error {
statuses, err := reader.Status(ctx)
if err != nil {
return errors.New("database migration status failed")
}
if len(statuses) == 0 {
return errors.New("database has no known migrations")
}
for _, status := range statuses {
if !status.Applied {
return errors.New(
"database migrations are pending; run migrate up",
)
}
}
return nil
}
func publicProvisioningError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {
return errors.New("authentication provisioning failed")
}
switch typed.Kind {
case usecase.ErrorKindInvalid:
return errors.New("authentication input is invalid")
case usecase.ErrorKindConflict:
return errors.New("authentication resource already exists")
case usecase.ErrorKindUnavailable:
return errors.New("authentication storage is unavailable")
case usecase.ErrorKindNotFound:
return errors.New("authentication resource was not found")
default:
return errors.New("authentication provisioning failed")
}
}