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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestRunCreatesUserWithoutExposingPassword(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
const secret = "correct-horse-password"
|
||||
lookup := testLookup(databasePath, secret)
|
||||
var output bytes.Buffer
|
||||
|
||||
err := run(
|
||||
[]string{"create-user", "ADMIN", "Admin01"},
|
||||
lookup,
|
||||
&output,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("run(create-user) error = %v", err)
|
||||
}
|
||||
if strings.Contains(output.String(), secret) ||
|
||||
!strings.Contains(output.String(), "username=admin01 role=ADMIN") {
|
||||
t.Fatalf("output = %q", output.String())
|
||||
}
|
||||
db, err := database.Open(context.Background(), databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var hash string
|
||||
if err := db.QueryRow(
|
||||
"SELECT password_hash FROM users WHERE username = 'admin01'",
|
||||
).Scan(&hash); err != nil {
|
||||
t.Fatalf("query user: %v", err)
|
||||
}
|
||||
if hash == secret ||
|
||||
bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
|
||||
t.Fatal("stored password is missing, plaintext, or invalid")
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
err = run(
|
||||
[]string{"create-user", "ADMIN", "admin01"},
|
||||
lookup,
|
||||
&output,
|
||||
)
|
||||
if err == nil ||
|
||||
strings.Contains(err.Error(), secret) ||
|
||||
output.Len() != 0 {
|
||||
t.Fatalf("duplicate error/output = %v / %q", err, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCreatesDeviceAndStoresOnlyTokenHash(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
var output bytes.Buffer
|
||||
|
||||
err := run(
|
||||
[]string{"create-device", "buyer-phone-01"},
|
||||
testLookup(databasePath, ""),
|
||||
&output,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("run(create-device) error = %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
||||
if len(lines) != 2 ||
|
||||
!strings.HasPrefix(lines[0], "device_id=") ||
|
||||
!strings.HasPrefix(lines[1], "device_token=") {
|
||||
t.Fatalf("output = %q", output.String())
|
||||
}
|
||||
deviceID := strings.TrimPrefix(lines[0], "device_id=")
|
||||
token := strings.TrimPrefix(lines[1], "device_token=")
|
||||
db, err := database.Open(context.Background(), databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var tokenHash string
|
||||
if err := db.QueryRow(
|
||||
"SELECT token_hash FROM devices WHERE id = ?",
|
||||
deviceID,
|
||||
).Scan(&tokenHash); err != nil {
|
||||
t.Fatalf("query device: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
if tokenHash == token ||
|
||||
tokenHash != hex.EncodeToString(sum[:]) {
|
||||
t.Fatal("stored device token is not the expected hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEnablesAndDisablesUsersAndDevices(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
lookup := testLookup(databasePath, "correct-horse-password")
|
||||
if err := run(
|
||||
[]string{"create-user", "BUYER", "buyer01"},
|
||||
lookup,
|
||||
&bytes.Buffer{},
|
||||
); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var deviceOutput bytes.Buffer
|
||||
if err := run(
|
||||
[]string{"create-device", "buyer-phone-01"},
|
||||
lookup,
|
||||
&deviceOutput,
|
||||
); err != nil {
|
||||
t.Fatalf("create device: %v", err)
|
||||
}
|
||||
deviceID := strings.TrimPrefix(
|
||||
strings.Split(strings.TrimSpace(deviceOutput.String()), "\n")[0],
|
||||
"device_id=",
|
||||
)
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := run(
|
||||
[]string{"disable-user", "BUYER01"},
|
||||
lookup,
|
||||
&output,
|
||||
); err != nil {
|
||||
t.Fatalf("disable user: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "username=buyer01 active=false") {
|
||||
t.Fatalf("disable user output = %q", output.String())
|
||||
}
|
||||
output.Reset()
|
||||
if err := run(
|
||||
[]string{"disable-device", deviceID},
|
||||
lookup,
|
||||
&output,
|
||||
); err != nil {
|
||||
t.Fatalf("disable device: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Open(context.Background(), databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var userActive, deviceEnabled bool
|
||||
if err := db.QueryRow(
|
||||
"SELECT is_active FROM users WHERE username = 'buyer01'",
|
||||
).Scan(&userActive); err != nil {
|
||||
t.Fatalf("query user status: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
"SELECT is_enabled FROM devices WHERE id = ?",
|
||||
deviceID,
|
||||
).Scan(&deviceEnabled); err != nil {
|
||||
t.Fatalf("query device status: %v", err)
|
||||
}
|
||||
if userActive || deviceEnabled {
|
||||
t.Fatalf(
|
||||
"user/device enabled = %v/%v",
|
||||
userActive,
|
||||
deviceEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsWeakPasswordAndInvalidCommands(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
err := run(
|
||||
[]string{"create-user", "BUYER", "buyer"},
|
||||
testLookup(databasePath, "too-short"),
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil || err.Error() != "authentication input is invalid" {
|
||||
t.Fatalf("weak password error = %v", err)
|
||||
}
|
||||
for _, arguments := range [][]string{
|
||||
nil,
|
||||
{"create-user", "OWNER", "user"},
|
||||
{"create-device"},
|
||||
{"unknown"},
|
||||
} {
|
||||
if err := run(
|
||||
arguments,
|
||||
testLookup(databasePath, ""),
|
||||
&bytes.Buffer{},
|
||||
); err == nil {
|
||||
t.Fatalf("run(%v) error = nil", arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresCurrentMigrations(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "pending.db")
|
||||
err := run(
|
||||
[]string{"create-device", "phone"},
|
||||
testLookup(databasePath, ""),
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil ||
|
||||
err.Error() != "database migrations are pending; run migrate up" {
|
||||
t.Fatalf("pending migration error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migratedDatabase(t *testing.T) string {
|
||||
t.Helper()
|
||||
databasePath := filepath.Join(t.TempDir(), "authctl.db")
|
||||
db, err := database.Open(context.Background(), databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
runner, err := migration.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if _, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("migration.Up() error = %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("db.Close() error = %v", err)
|
||||
}
|
||||
return databasePath
|
||||
}
|
||||
|
||||
func testLookup(
|
||||
databasePath string,
|
||||
password string,
|
||||
) config.LookupEnvironment {
|
||||
return func(name string) (string, bool) {
|
||||
switch name {
|
||||
case config.DatabasePathEnvironment:
|
||||
return databasePath, true
|
||||
case passwordEnvironment:
|
||||
if password == "" {
|
||||
return "", false
|
||||
}
|
||||
return password, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user