diff --git a/.gitignore b/.gitignore index 4a943ed..e133da3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ config.yaml -gordio +/gordio diff --git a/README.md b/README.md index d2efa8a..216ee86 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # stillbox -A Rust scanner call server, with the Calls webapp. \ No newline at end of file +A Golang scanner call server, with the Calls webapp. diff --git a/pkg/gordio/admin/admin.go b/pkg/gordio/admin/admin.go new file mode 100644 index 0000000..29e34e3 --- /dev/null +++ b/pkg/gordio/admin/admin.go @@ -0,0 +1,108 @@ +package admin + +import ( + "context" + "errors" + "fmt" + "syscall" + + "dynatron.me/x/stillbox/pkg/gordio/config" + "dynatron.me/x/stillbox/pkg/gordio/database" + "github.com/jackc/pgx/v5/pgtype" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +const ( + PromptPassword = "Password: " + PromptAgain = "Again: " +) + +var ( + ErrDontMatch = errors.New("passwords do not match") + ErrInvalidArguments = errors.New("invalid arguments") +) + +func addUser(cfg *config.Config, username, email string, isAdmin bool) error { + if username == "" || email == "" { + return ErrInvalidArguments + } + + db, err := database.NewClient(cfg.DB) + if err != nil { + return err + } + + pw, err := readPassword(PromptPassword) + if err != nil { + return err + } + + pwAgain, err := readPassword(PromptAgain) + if err != nil { + return err + } + + if pwAgain != pw { + return ErrDontMatch + } + + if pw == "" { + return ErrInvalidArguments + } + + hashpw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + + _, err = database.New(db).CreateUser(context.Background(), database.CreateUserParams{ + Username: username, + Password: string(hashpw), + Email: email, + IsAdmin: pgtype.Bool{Bool: isAdmin, Valid: true}, + }) + + return err +} + +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + return string(pw), err +} + +func Command(cfg *config.Config) []*cobra.Command { + userCmd := &cobra.Command{ + Use: "users", + Aliases: []string{"u"}, + Short: "administers the server", + } + userCmd.AddCommand(addUserCommand(cfg)) + + return []*cobra.Command{userCmd} +} + +func addUserCommand(cfg *config.Config) *cobra.Command { + c := &cobra.Command{ + Use: "add", + Short: "adds a user", + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + isAdmin, err := cmd.Flags().GetBool("admin") + if err != nil { + return err + } + email, err := cmd.Flags().GetString("email") + if err != nil { + return err + } + + return addUser(cfg, username, email, isAdmin) + }, + Args: cobra.ExactArgs(1), + } + c.Flags().BoolP("admin", "a", false, "is admin") + c.Flags().StringP("email", "m", "", "email address") + + return c +} diff --git a/pkg/gordio/database/database.go b/pkg/gordio/database/database.go new file mode 100644 index 0000000..5e59ef4 --- /dev/null +++ b/pkg/gordio/database/database.go @@ -0,0 +1,59 @@ +package database + +import ( + "context" + "errors" + "strings" + + "dynatron.me/x/stillbox/pkg/gordio/config" + sqlembed "dynatron.me/x/stillbox/sql" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Conn = *pgxpool.Pool + +func NewClient(conf config.DB) (Conn, error) { + dir, err := iofs.New(sqlembed.Migrations, "postgres/migrations") + if err != nil { + return nil, err + } + + m, err := migrate.NewWithSourceInstance("iofs", dir, strings.Replace(conf.Connect, "postgres://", "pgx5://", 1)) // yech + if err != nil { + return nil, err + } + + err = m.Up() + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return nil, err + } + + m.Close() + + db, err := pgxpool.New(context.Background(), conf.Connect) + if err != nil { + return nil, err + } + + return db, nil +} + +type DBCtxKey string + +const DBCTXKeyValue DBCtxKey = "dbctx" + +func FromCtx(ctx context.Context) Conn { + c, ok := ctx.Value(DBCTXKeyValue).(Conn) + if !ok { + panic("no DB in context") + } + + return c +} + +func CtxWithDB(ctx context.Context, conn Conn) context.Context { + return context.WithValue(ctx, DBCTXKeyValue, conn) +} diff --git a/pkg/gordio/database/db.go b/pkg/gordio/database/db.go new file mode 100644 index 0000000..1d02744 --- /dev/null +++ b/pkg/gordio/database/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package database + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/pkg/gordio/database/models.go b/pkg/gordio/database/models.go new file mode 100644 index 0000000..95cfb8e --- /dev/null +++ b/pkg/gordio/database/models.go @@ -0,0 +1,28 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package database + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Apikey struct { + ID int32 + Owner pgtype.Int4 + Apikey string +} + +type Call struct { + ID pgtype.UUID +} + +type User struct { + ID int32 + Username string + Password string + Email string + IsAdmin pgtype.Bool + Prefs []byte +} diff --git a/pkg/gordio/database/query.sql.go b/pkg/gordio/database/query.sql.go new file mode 100644 index 0000000..4f8b3b5 --- /dev/null +++ b/pkg/gordio/database/query.sql.go @@ -0,0 +1,117 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: query.sql + +package database + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + username, + password, + email, + is_admin + ) VALUES ($1, $2, $3, $4) +RETURNING id, username, password, email, is_admin, prefs +` + +type CreateUserParams struct { + Username string + Password string + Email string + IsAdmin pgtype.Bool +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Username, + arg.Password, + arg.Email, + arg.IsAdmin, + ) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Email, + &i.IsAdmin, + &i.Prefs, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password, email, is_admin, prefs FROM users +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Email, + &i.IsAdmin, + &i.Prefs, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password, email, is_admin, prefs FROM users +WHERE username = $1 LIMIT 1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Email, + &i.IsAdmin, + &i.Prefs, + ) + return i, err +} + +const getUsers = `-- name: GetUsers :many +SELECT id, username, password, email, is_admin, prefs FROM users +` + +func (q *Queries) GetUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, getUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Email, + &i.IsAdmin, + &i.Prefs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/gordio/server/auth.go b/pkg/gordio/server/auth.go new file mode 100644 index 0000000..adacfc9 --- /dev/null +++ b/pkg/gordio/server/auth.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "errors" + "golang.org/x/crypto/bcrypt" + "net/http" + "time" + + "dynatron.me/x/stillbox/pkg/gordio/database" + + "github.com/go-chi/jwtauth/v5" + "github.com/rs/zerolog/log" +) + +type claims map[string]interface{} + +func (s *Server) Authenticated(r *http.Request) (claims, bool) { + // TODO: check IP against ACL, or conf.Public, and against map of routes + tok, cl, err := jwtauth.FromContext(r.Context()) + return cl, err != nil && tok != nil +} + +var ( + ErrLoginFailed = errors.New("Login failed") +) + +func (s *Server) Login(ctx context.Context, username, password string) (token string, err error) { + q := database.New(database.FromCtx(ctx)) + users, err := q.GetUsers(ctx) + if err != nil { + log.Error().Err(err).Msg("getUsers failed") + return "", ErrLoginFailed + } + + var found *database.User + + for _, u := range users { + if u.Username == username { + found = &u + } + } + + if found == nil { + return "", ErrLoginFailed + } + + err = bcrypt.CompareHashAndPassword([]byte(found.Password), []byte(password)) + if err != nil { + return "", ErrLoginFailed + } + + return s.NewToken(found.ID), nil +} + +func (s *Server) NewToken(uid int32) string { + claims := claims{ + "user_id": uid, + } + jwtauth.SetExpiryIn(claims, time.Hour*24*30) // one month + _, tokenString, err := s.jwt.Encode(claims) + if err != nil { + panic(err) + } + return tokenString +}