-
Notifications
You must be signed in to change notification settings - Fork 928
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
Changes from all commits
edad1ca
40be7bd
129a10b
6394949
76a1e75
c652f73
ad85b71
34d898a
54b4576
9c64fd5
323ed6e
962b549
931fd82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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, | ||
} | ||
} |
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) | ||
} |
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) | ||
} |
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) | ||
}) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.