Skip to content

feat: Add authentication and personal user endpoint #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ else
endif
.PHONY: fmt/prettier

fmt: fmt/prettier
fmt/sql:
npx sql-formatter \
--language postgresql \
--lines-between-queries 2 \
./database/query.sql \
--output ./database/query.sql

fmt: fmt/prettier fmt/sql
.PHONY: fmt

gen: database/generate peerbroker/proto provisionersdk/proto
Expand Down
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ coverage:

ignore:
# This is generated code.
- database/models.go
- database/query.sql.go
- peerbroker/proto
- provisionersdk/proto
9 changes: 5 additions & 4 deletions coderd/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import (
"net/http"
"os"

"github.com/spf13/cobra"
"golang.org/x/xerrors"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/database/databasefake"
)

func Root() *cobra.Command {
Expand All @@ -22,7 +23,7 @@ func Root() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
handler := coderd.New(&coderd.Options{
Logger: slog.Make(sloghuman.Sink(os.Stderr)),
Database: database.NewInMemory(),
Database: databasefake.New(),
})

listener, err := net.Listen("tcp", address)
Expand Down
3 changes: 2 additions & 1 deletion coderd/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import (
"context"
"testing"

"github.com/coder/coder/coderd/cmd"
"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/cmd"
)

func TestRoot(t *testing.T) {
Expand Down
24 changes: 19 additions & 5 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package coderd
import (
"net/http"

"github.com/go-chi/chi"

"cdr.dev/slog"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
"github.com/coder/coder/site"
"github.com/go-chi/chi"
"github.com/go-chi/render"
)

// Options are requires parameters for Coder to start.
Expand All @@ -18,15 +20,27 @@ type Options struct {

// New constructs the Coder API into an HTTP handler.
func New(options *Options) http.Handler {
users := &users{
Database: options.Database,
}

r := chi.NewRouter()
r.Route("/api/v2", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, struct {
Message string `json:"message"`
}{
httpapi.Write(w, http.StatusOK, httpapi.Response{
Message: "👋",
})
})
r.Post("/user", users.createInitialUser)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected that the front-end will have to call this? Or we'll just run curl and POST to it in our ./develop.sh, to ensure that it's created?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The front-end will call this!

v2 will have no concept of a "setup mode".

r.Post("/login", users.loginWithPassword)
// Require an API key and authenticated user for this group.
r.Group(func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
httpmw.ExtractUser(options.Database),
)
r.Get("/user", users.getAuthenticatedUser)
})
})
r.NotFound(site.Handler().ServeHTTP)
return r
Expand Down
59 changes: 59 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package coderdtest

import (
"context"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/require"

"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database/databasefake"
)

// Server represents a test instance of coderd.
// The database is intentionally omitted from
// this struct to promote data being exposed via
// the API.
type Server struct {
Client *codersdk.Client
URL *url.URL
}

// New constructs a new coderd test instance.
func New(t *testing.T) Server {
// This can be hotswapped for a live database instance.
db := databasefake.New()
handler := coderd.New(&coderd.Options{
Logger: slogtest.Make(t, nil),
Database: db,
})
srv := httptest.NewServer(handler)
u, err := url.Parse(srv.URL)
require.NoError(t, err)
t.Cleanup(srv.Close)

client := codersdk.New(u)
_, err = client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpassword",
})
require.NoError(t, err)

login, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "testuser@coder.com",
Password: "testpassword",
})
require.NoError(t, err)
err = client.SetSessionToken(login.SessionToken)
require.NoError(t, err)

return Server{
Client: client,
URL: u,
}
}
17 changes: 17 additions & 0 deletions coderd/coderdtest/coderdtest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package coderdtest_test

import (
"testing"

"go.uber.org/goleak"

"github.com/coder/coder/coderd/coderdtest"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

func TestNew(t *testing.T) {
_ = coderdtest.New(t)
}
78 changes: 78 additions & 0 deletions coderd/userpassword/userpassword.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package userpassword

import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"

"golang.org/x/crypto/pbkdf2"
"golang.org/x/xerrors"
)

const (
// This is the length of our output hash.
// bcrypt has a hash size of 59, so we rounded up to a power of 8.
hashLength = 64
// The scheme to include in our hashed password.
hashScheme = "pbkdf2-sha256"
)

// Compare checks the equality of passwords from a hashed pbkdf2 string.
// This uses pbkdf2 to ensure FIPS 140-2 compliance. See:
// https://csrc.nist.gov/csrc/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
func Compare(hashed string, password string) (bool, error) {
if len(hashed) < hashLength {
return false, xerrors.Errorf("hash too short: %d", len(hashed))
}
parts := strings.SplitN(hashed, "$", 5)
if len(parts) != 5 {
return false, xerrors.Errorf("hash has too many parts: %d", len(parts))
}
if len(parts[0]) != 0 {
return false, xerrors.Errorf("hash prefix is invalid")
}
if string(parts[1]) != hashScheme {
return false, xerrors.Errorf("hash isn't %q scheme: %q", hashScheme, parts[1])
}
iter, err := strconv.Atoi(string(parts[2]))
if err != nil {
return false, xerrors.Errorf("parse iter from hash: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(string(parts[3]))
if err != nil {
return false, xerrors.Errorf("decode salt: %w", err)
}

if subtle.ConstantTimeCompare([]byte(hashWithSaltAndIter(password, salt, iter)), []byte(hashed)) != 1 {
return false, nil
}
return true, nil
}

// Hash generates a hash using pbkdf2.
// See the Compare() comment for rationale.
func Hash(password string) (string, error) {
// bcrypt uses a salt size of 16 bytes.
salt := make([]byte, 16)
_, err := rand.Read(salt)
if err != nil {
return "", xerrors.Errorf("read random bytes for salt: %w", err)
}
// The default hash iteration is 1024 for speed.
// As this is increased, the password is hashed more.
return hashWithSaltAndIter(password, salt, 1024), nil
}

// Produces a string representation of the hash.
func hashWithSaltAndIter(password string, salt []byte, iter int) string {
hash := pbkdf2.Key([]byte(password), salt, iter, hashLength, sha256.New)
hash = []byte(base64.RawStdEncoding.EncodeToString(hash))
salt = []byte(base64.RawStdEncoding.EncodeToString(salt))
// This format is similar to bcrypt. See:
// https://en.wikipedia.org/wiki/Bcrypt#Description
return fmt.Sprintf("$%s$%d$%s$%s", hashScheme, iter, salt, hash)
}
47 changes: 47 additions & 0 deletions coderd/userpassword/userpassword_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package userpassword_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/userpassword"
)

func TestUserPassword(t *testing.T) {
t.Run("Legacy", func(t *testing.T) {
// Ensures legacy v1 passwords function for v2.
// This has is manually generated using a print statement from v1 code.
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
require.NoError(t, err)
require.True(t, equal)
})

t.Run("Same", func(t *testing.T) {
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "password")
require.NoError(t, err)
require.True(t, equal)
})

t.Run("Different", func(t *testing.T) {
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "notpassword")
require.NoError(t, err)
require.False(t, equal)
})

t.Run("Invalid", func(t *testing.T) {
equal, err := userpassword.Compare("invalidhash", "password")
require.False(t, equal)
require.Error(t, err)
})

t.Run("InvalidParts", func(t *testing.T) {
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
require.False(t, equal)
require.Error(t, err)
})
}
Loading