diff --git a/admin/cmd/adminctl/main.go b/admin/cmd/adminctl/main.go new file mode 100644 index 0000000..ef52530 --- /dev/null +++ b/admin/cmd/adminctl/main.go @@ -0,0 +1,82 @@ +// Command adminctl performs the operations that deliberately have no HTTP +// surface. +// +// adminctl staff-add --email=you@example.com --name="You" --password=... +// +// There is no staff signup endpoint. A licensing authority that can be joined +// over the internet is not one. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "golang.org/x/crypto/bcrypt" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") + os.Exit(2) + } + + cfg, err := config.Load() + if err != nil { + fatal("configuration: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := db.Connect(ctx, cfg); err != nil { + fatal("database: %v", err) + } + + switch os.Args[1] { + case "staff-add": + staffAdd(ctx, os.Args[2:]) + default: + fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") + os.Exit(2) + } +} + +func staffAdd(ctx context.Context, args []string) { + fs := flag.NewFlagSet("staff-add", flag.ExitOnError) + email := fs.String("email", "", "staff email (required)") + name := fs.String("name", "", "display name") + password := fs.String("password", "", "password, at least 12 characters (required)") + fs.Parse(args) + + if *email == "" || len(*password) < 12 { + fatal("--email and a --password of at least 12 characters are required") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12) + if err != nil { + fatal("hash: %v", err) + } + + u := models.StaffUser{ + UserID: uuid.NewString(), + Email: strings.ToLower(strings.TrimSpace(*email)), + PasswordHash: string(hash), + Name: *name, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil { + fatal("create staff user: %v", err) + } + fmt.Printf("created staff user %s\n", u.Email) +} + +func fatal(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} diff --git a/admin/cmd/main.go b/admin/cmd/main.go index b755cbc..8c800b1 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/mrhid6/vantage/admin/internal/auth" "github.com/mrhid6/vantage/admin/internal/config" "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/inject" @@ -28,6 +29,14 @@ func main() { licensing.SetSigningKey(cfg.SigningKey) + auth.InitRedis(cfg.RedisAddr) + pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := auth.Ping(pingCtx); err != nil { + pingCancel() + log.Fatalf("redis: %v", err) + } + pingCancel() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) if err := db.Connect(ctx, cfg); err != nil { cancel() diff --git a/admin/go.mod b/admin/go.mod index e12fcd5..bea216c 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -3,22 +3,53 @@ module github.com/mrhid6/vantage/admin go 1.26.4 require ( + github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 + github.com/redis/go-redis/v9 v9.20.1 go.mongodb.org/mongo-driver/v2 v2.8.0 + golang.org/x/crypto v0.54.0 ) require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.54.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/mrhid6/vantage/shared => ../shared diff --git a/admin/go.sum b/admin/go.sum index 3e94703..83bc835 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -1,19 +1,93 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0= github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= +github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -23,8 +97,14 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= @@ -33,6 +113,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -42,6 +124,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -54,5 +139,11 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/admin/internal/auth/middleware.go b/admin/internal/auth/middleware.go new file mode 100644 index 0000000..e1211df --- /dev/null +++ b/admin/internal/auth/middleware.go @@ -0,0 +1,58 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const ctxSession = "admin_session_obj" + +func load(c *gin.Context) *Session { + id, err := c.Cookie(CookieName) + if err != nil || id == "" { + return nil + } + s, err := Get(c.Request.Context(), id) + if err != nil { + return nil + } + return s +} + +// Current returns the session, or nil. +func Current(c *gin.Context) *Session { + if v, ok := c.Get(ctxSession); ok { + if s, ok := v.(*Session); ok { + return s + } + } + return nil +} + +func RequireStaff() gin.HandlerFunc { + return func(c *gin.Context) { + s := load(c) + if s == nil || s.Kind != KindStaff { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) + return + } + c.Set(ctxSession, s) + c.Next() + } +} + +// RequireCustomer admits both cloud and self-hosted customers. Every handler +// behind it scopes by AccountID via the helper in api/customer.go — never by +// remembering to filter. +func RequireCustomer() gin.HandlerFunc { + return func(c *gin.Context) { + s := load(c) + if s == nil || s.Kind != KindCustomer || s.AccountID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) + return + } + c.Set(ctxSession, s) + c.Next() + } +} diff --git a/admin/internal/auth/ratelimit.go b/admin/internal/auth/ratelimit.go new file mode 100644 index 0000000..e516948 --- /dev/null +++ b/admin/internal/auth/ratelimit.go @@ -0,0 +1,55 @@ +package auth + +import ( + "sync" + "time" +) + +// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per +// hour. The email limit stops a targeted attack on one account; the IP limit +// stops a spray across many. +const ( + emailLimit = 5 + emailWindow = 15 * time.Minute + ipLimit = 20 + ipWindow = time.Hour +) + +var ( + attemptMu sync.Mutex + byEmail = map[string][]time.Time{} + byIP = map[string][]time.Time{} +) + +func prune(in []time.Time, cutoff time.Time) []time.Time { + out := in[:0] + for _, t := range in { + if t.After(cutoff) { + out = append(out, t) + } + } + return out +} + +func allowAttempt(email, ip string) bool { + now := time.Now() + + attemptMu.Lock() + defer attemptMu.Unlock() + + byEmail[email] = prune(byEmail[email], now.Add(-emailWindow)) + byIP[ip] = prune(byIP[ip], now.Add(-ipWindow)) + + if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit { + return false + } + byEmail[email] = append(byEmail[email], now) + byIP[ip] = append(byIP[ip], now) + return true +} + +func clearAttempts(email string) { + attemptMu.Lock() + delete(byEmail, email) + attemptMu.Unlock() +} diff --git a/admin/internal/auth/session.go b/admin/internal/auth/session.go new file mode 100644 index 0000000..9c7184c --- /dev/null +++ b/admin/internal/auth/session.go @@ -0,0 +1,90 @@ +// Package auth holds admin's three identities: staff, cloud customers and +// self-hosted customers. All three share one session store and one cookie. +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const ( + CookieName = "admin_session" + SessionTTL = 24 * time.Hour + KindStaff = "staff" + KindCustomer = "customer" +) + +type Session struct { + UserID string `json:"user_id"` + Kind string `json:"kind"` + Email string `json:"email"` + AccountID string `json:"account_id,omitempty"` // customers only +} + +var rdb *redis.Client + +func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) } + +func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() } + +func newID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func Save(ctx context.Context, s Session) (string, error) { + id, err := newID() + if err != nil { + return "", err + } + body, err := json.Marshal(s) + if err != nil { + return "", err + } + if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil { + return "", err + } + return id, nil +} + +func Get(ctx context.Context, id string) (*Session, error) { + body, err := rdb.Get(ctx, "admin_session:"+id).Bytes() + if err != nil { + return nil, err + } + var s Session + if err := json.Unmarshal(body, &s); err != nil { + return nil, err + } + return &s, nil +} + +func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) } + +func SetCookie(c *gin.Context, id string) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: CookieName, + Value: id, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(SessionTTL.Seconds()), + }) +} + +func ClearCookie(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1, + }) +} diff --git a/admin/internal/auth/staff.go b/admin/internal/auth/staff.go new file mode 100644 index 0000000..b6d2c8d --- /dev/null +++ b/admin/internal/auth/staff.go @@ -0,0 +1,77 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/crypto/bcrypt" +) + +// genericAuthError is returned for every failure mode — unknown email, wrong +// password, wrong role. Distinguishing them would confirm which addresses have +// accounts. +const genericAuthError = "email or password is incorrect" + +func HandleStaffLogin(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) + return + } + email := strings.ToLower(strings.TrimSpace(body.Email)) + + if !allowAttempt(email, c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + var u models.StaffUser + err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u) + if err != nil { + // Spend the same work as a real comparison so timing does not + // distinguish "no such user" from "wrong password". + bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) + return + } + + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) + return + } + + id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) + return + } + SetCookie(c, id) + clearAttempts(email) + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: email, Action: "staff.login", IP: c.ClientIP()}) + c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name}) +} + +// dummyHash is a valid bcrypt hash of a random value, compared against when no +// user exists so the timing profile matches. +const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u" + +func HandleLogout(c *gin.Context) { + if id, err := c.Cookie(CookieName); err == nil && id != "" { + Destroy(c.Request.Context(), id) + } + ClearCookie(c) + c.JSON(http.StatusOK, gin.H{"ok": true}) +}