feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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" {
|
||||
var exists bool
|
||||
plainPassword, exists = lookup(passwordEnvironment)
|
||||
if !exists || plainPassword == "" {
|
||||
return errors.New(
|
||||
passwordEnvironment + " must be set for create-user",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 "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 "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 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user