Skip to content

Commit

Permalink
feat: Add authentication and personal user endpoint (#29)
Browse files Browse the repository at this point in the history
* feat: Add authentication and personal user endpoint

This contribution adds a lot of scaffolding for the database fake
and testability of coderd.

A new endpoint "/user" is added to return the currently authenticated
user to the requester.

* Use TestMain to catch leak instead

* Add userpassword package

* Add WIP

* Add user auth

* Fix test

* Add comments

* Fix login response

* Fix order

* Fix generated code

* Update httpapi/httpapi.go

Co-authored-by: Bryan <bryan@coder.com>

Co-authored-by: Bryan <bryan@coder.com>
  • Loading branch information
kylecarbs and bryphe-coder committed Jan 20, 2022
1 parent 36b7b20 commit 6a919ae
Show file tree
Hide file tree
Showing 39 changed files with 2,232 additions and 61 deletions.
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)
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)
})
}

0 comments on commit 6a919ae

Please sign in to comment.