Skip to content

Commit 6a919ae

Browse files
feat: Add authentication and personal user endpoint (#29)
* 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>
1 parent 36b7b20 commit 6a919ae

39 files changed

+2232
-61
lines changed

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ else
2727
endif
2828
.PHONY: fmt/prettier
2929

30-
fmt: fmt/prettier
30+
fmt/sql:
31+
npx sql-formatter \
32+
--language postgresql \
33+
--lines-between-queries 2 \
34+
./database/query.sql \
35+
--output ./database/query.sql
36+
37+
fmt: fmt/prettier fmt/sql
3138
.PHONY: fmt
3239

3340
gen: database/generate peerbroker/proto provisionersdk/proto

codecov.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ coverage:
2121

2222
ignore:
2323
# This is generated code.
24+
- database/models.go
25+
- database/query.sql.go
2426
- peerbroker/proto
2527
- provisionersdk/proto

coderd/cmd/root.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import (
55
"net/http"
66
"os"
77

8+
"github.com/spf13/cobra"
9+
"golang.org/x/xerrors"
10+
811
"cdr.dev/slog"
912
"cdr.dev/slog/sloggers/sloghuman"
1013
"github.com/coder/coder/coderd"
11-
"github.com/coder/coder/database"
12-
"github.com/spf13/cobra"
13-
"golang.org/x/xerrors"
14+
"github.com/coder/coder/database/databasefake"
1415
)
1516

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

2829
listener, err := net.Listen("tcp", address)

coderd/cmd/root_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import (
44
"context"
55
"testing"
66

7-
"github.com/coder/coder/coderd/cmd"
87
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/coderd/cmd"
910
)
1011

1112
func TestRoot(t *testing.T) {

coderd/coderd.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package coderd
33
import (
44
"net/http"
55

6+
"github.com/go-chi/chi"
7+
68
"cdr.dev/slog"
79
"github.com/coder/coder/database"
10+
"github.com/coder/coder/httpapi"
11+
"github.com/coder/coder/httpmw"
812
"github.com/coder/coder/site"
9-
"github.com/go-chi/chi"
10-
"github.com/go-chi/render"
1113
)
1214

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

1921
// New constructs the Coder API into an HTTP handler.
2022
func New(options *Options) http.Handler {
23+
users := &users{
24+
Database: options.Database,
25+
}
26+
2127
r := chi.NewRouter()
2228
r.Route("/api/v2", func(r chi.Router) {
2329
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
24-
render.JSON(w, r, struct {
25-
Message string `json:"message"`
26-
}{
30+
httpapi.Write(w, http.StatusOK, httpapi.Response{
2731
Message: "👋",
2832
})
2933
})
34+
r.Post("/user", users.createInitialUser)
35+
r.Post("/login", users.loginWithPassword)
36+
// Require an API key and authenticated user for this group.
37+
r.Group(func(r chi.Router) {
38+
r.Use(
39+
httpmw.ExtractAPIKey(options.Database, nil),
40+
httpmw.ExtractUser(options.Database),
41+
)
42+
r.Get("/user", users.getAuthenticatedUser)
43+
})
3044
})
3145
r.NotFound(site.Handler().ServeHTTP)
3246
return r

coderd/coderdtest/coderdtest.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package coderdtest
2+
3+
import (
4+
"context"
5+
"net/http/httptest"
6+
"net/url"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"cdr.dev/slog/sloggers/slogtest"
12+
"github.com/coder/coder/coderd"
13+
"github.com/coder/coder/codersdk"
14+
"github.com/coder/coder/database/databasefake"
15+
)
16+
17+
// Server represents a test instance of coderd.
18+
// The database is intentionally omitted from
19+
// this struct to promote data being exposed via
20+
// the API.
21+
type Server struct {
22+
Client *codersdk.Client
23+
URL *url.URL
24+
}
25+
26+
// New constructs a new coderd test instance.
27+
func New(t *testing.T) Server {
28+
// This can be hotswapped for a live database instance.
29+
db := databasefake.New()
30+
handler := coderd.New(&coderd.Options{
31+
Logger: slogtest.Make(t, nil),
32+
Database: db,
33+
})
34+
srv := httptest.NewServer(handler)
35+
u, err := url.Parse(srv.URL)
36+
require.NoError(t, err)
37+
t.Cleanup(srv.Close)
38+
39+
client := codersdk.New(u)
40+
_, err = client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
41+
Email: "testuser@coder.com",
42+
Username: "testuser",
43+
Password: "testpassword",
44+
})
45+
require.NoError(t, err)
46+
47+
login, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
48+
Email: "testuser@coder.com",
49+
Password: "testpassword",
50+
})
51+
require.NoError(t, err)
52+
err = client.SetSessionToken(login.SessionToken)
53+
require.NoError(t, err)
54+
55+
return Server{
56+
Client: client,
57+
URL: u,
58+
}
59+
}

coderd/coderdtest/coderdtest_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package coderdtest_test
2+
3+
import (
4+
"testing"
5+
6+
"go.uber.org/goleak"
7+
8+
"github.com/coder/coder/coderd/coderdtest"
9+
)
10+
11+
func TestMain(m *testing.M) {
12+
goleak.VerifyTestMain(m)
13+
}
14+
15+
func TestNew(t *testing.T) {
16+
_ = coderdtest.New(t)
17+
}

coderd/userpassword/userpassword.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package userpassword
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/sha256"
6+
"crypto/subtle"
7+
"encoding/base64"
8+
"fmt"
9+
"strconv"
10+
"strings"
11+
12+
"golang.org/x/crypto/pbkdf2"
13+
"golang.org/x/xerrors"
14+
)
15+
16+
const (
17+
// This is the length of our output hash.
18+
// bcrypt has a hash size of 59, so we rounded up to a power of 8.
19+
hashLength = 64
20+
// The scheme to include in our hashed password.
21+
hashScheme = "pbkdf2-sha256"
22+
)
23+
24+
// Compare checks the equality of passwords from a hashed pbkdf2 string.
25+
// This uses pbkdf2 to ensure FIPS 140-2 compliance. See:
26+
// https://csrc.nist.gov/csrc/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
27+
func Compare(hashed string, password string) (bool, error) {
28+
if len(hashed) < hashLength {
29+
return false, xerrors.Errorf("hash too short: %d", len(hashed))
30+
}
31+
parts := strings.SplitN(hashed, "$", 5)
32+
if len(parts) != 5 {
33+
return false, xerrors.Errorf("hash has too many parts: %d", len(parts))
34+
}
35+
if len(parts[0]) != 0 {
36+
return false, xerrors.Errorf("hash prefix is invalid")
37+
}
38+
if string(parts[1]) != hashScheme {
39+
return false, xerrors.Errorf("hash isn't %q scheme: %q", hashScheme, parts[1])
40+
}
41+
iter, err := strconv.Atoi(string(parts[2]))
42+
if err != nil {
43+
return false, xerrors.Errorf("parse iter from hash: %w", err)
44+
}
45+
salt, err := base64.RawStdEncoding.DecodeString(string(parts[3]))
46+
if err != nil {
47+
return false, xerrors.Errorf("decode salt: %w", err)
48+
}
49+
50+
if subtle.ConstantTimeCompare([]byte(hashWithSaltAndIter(password, salt, iter)), []byte(hashed)) != 1 {
51+
return false, nil
52+
}
53+
return true, nil
54+
}
55+
56+
// Hash generates a hash using pbkdf2.
57+
// See the Compare() comment for rationale.
58+
func Hash(password string) (string, error) {
59+
// bcrypt uses a salt size of 16 bytes.
60+
salt := make([]byte, 16)
61+
_, err := rand.Read(salt)
62+
if err != nil {
63+
return "", xerrors.Errorf("read random bytes for salt: %w", err)
64+
}
65+
// The default hash iteration is 1024 for speed.
66+
// As this is increased, the password is hashed more.
67+
return hashWithSaltAndIter(password, salt, 1024), nil
68+
}
69+
70+
// Produces a string representation of the hash.
71+
func hashWithSaltAndIter(password string, salt []byte, iter int) string {
72+
hash := pbkdf2.Key([]byte(password), salt, iter, hashLength, sha256.New)
73+
hash = []byte(base64.RawStdEncoding.EncodeToString(hash))
74+
salt = []byte(base64.RawStdEncoding.EncodeToString(salt))
75+
// This format is similar to bcrypt. See:
76+
// https://en.wikipedia.org/wiki/Bcrypt#Description
77+
return fmt.Sprintf("$%s$%d$%s$%s", hashScheme, iter, salt, hash)
78+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package userpassword_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/coder/coder/coderd/userpassword"
9+
)
10+
11+
func TestUserPassword(t *testing.T) {
12+
t.Run("Legacy", func(t *testing.T) {
13+
// Ensures legacy v1 passwords function for v2.
14+
// This has is manually generated using a print statement from v1 code.
15+
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
16+
require.NoError(t, err)
17+
require.True(t, equal)
18+
})
19+
20+
t.Run("Same", func(t *testing.T) {
21+
hash, err := userpassword.Hash("password")
22+
require.NoError(t, err)
23+
equal, err := userpassword.Compare(hash, "password")
24+
require.NoError(t, err)
25+
require.True(t, equal)
26+
})
27+
28+
t.Run("Different", func(t *testing.T) {
29+
hash, err := userpassword.Hash("password")
30+
require.NoError(t, err)
31+
equal, err := userpassword.Compare(hash, "notpassword")
32+
require.NoError(t, err)
33+
require.False(t, equal)
34+
})
35+
36+
t.Run("Invalid", func(t *testing.T) {
37+
equal, err := userpassword.Compare("invalidhash", "password")
38+
require.False(t, equal)
39+
require.Error(t, err)
40+
})
41+
42+
t.Run("InvalidParts", func(t *testing.T) {
43+
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
44+
require.False(t, equal)
45+
require.Error(t, err)
46+
})
47+
}

0 commit comments

Comments
 (0)