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>
This commit is contained in:
Kyle Carberry
2022-01-20 13:46:51 +00:00
committed by GitHub
co-authored by Bryan
parent 36b7b20e2a
commit 6a919aea79
39 changed files with 2232 additions and 61 deletions
+5 -4
View File
@@ -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 {
@@ -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)
+2 -1
View File
@@ -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) {
+19 -5
View File
@@ -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)
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
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+209
View File
@@ -0,0 +1,209 @@
package coderd
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// User is the JSON representation of a Coder user.
type User struct {
ID string `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
}
// CreateUserRequest enables callers to create a new user.
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordResponse contains a session token for the newly authenticated user.
type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}
type users struct {
Database database.Store
}
// Creates the initial user for a Coder deployment.
func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
var createUser CreateUserRequest
if !httpapi.Read(rw, r, &createUser) {
return
}
// This should only function for the first user.
userCount, err := users.Database.GetUserCount(r.Context())
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user count: %s", err.Error()),
})
return
}
// If a user already exists, the initial admin user no longer can be created.
if userCount != 0 {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: "the initial user has already been created",
})
return
}
user, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
Email: createUser.Email,
Username: createUser.Username,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user: %s", err.Error()),
})
return
}
hashedPassword, err := userpassword.Hash(createUser.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("hash password: %s", err.Error()),
})
return
}
user, err = users.Database.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.NewString(),
Email: createUser.Email,
HashedPassword: []byte(hashedPassword),
Username: createUser.Username,
LoginType: database.LoginTypeBuiltIn,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("create user: %s", err.Error()),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, user)
}
// Returns the currently authenticated user.
func (users *users) getAuthenticatedUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.User(r)
render.JSON(rw, r, User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
Username: user.Username,
})
}
// Authenticates the user with an email and password.
func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) {
var loginWithPassword LoginWithPasswordRequest
if !httpapi.Read(rw, r, &loginWithPassword) {
return
}
user, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
Email: loginWithPassword.Email,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "invalid email or password",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user: %s", err.Error()),
})
return
}
equal, err := userpassword.Compare(string(user.HashedPassword), loginWithPassword.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("compare: %s", err.Error()),
})
}
if !equal {
// This message is the same as above to remove ease in detecting whether
// users are registered or not. Attackers still could with a timing attack.
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "invalid email or password",
})
return
}
id, secret, err := generateAPIKeyIDSecret()
hashed := sha256.Sum256([]byte(secret))
_, err = users.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
UserID: user.ID,
ExpiresAt: database.Now().Add(24 * time.Hour),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
HashedSecret: hashed[:],
LoginType: database.LoginTypeBuiltIn,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert api key: %s", err.Error()),
})
return
}
// This format is consumed by the APIKey middleware.
sessionToken := fmt.Sprintf("%s-%s", id, secret)
http.SetCookie(rw, &http.Cookie{
Name: httpmw.AuthCookie,
Value: sessionToken,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
render.Status(r, http.StatusCreated)
render.JSON(rw, r, LoginWithPasswordResponse{
SessionToken: sessionToken,
})
}
// Generates a new ID and secret for an API key.
func generateAPIKeyIDSecret() (string, string, error) {
// Length of an API Key ID.
id, err := cryptorand.String(10)
if err != nil {
return "", "", err
}
// Length of an API Key secret.
secret, err := cryptorand.String(22)
if err != nil {
return "", "", err
}
return id, secret, nil
}
+55
View File
@@ -0,0 +1,55 @@
package coderd_test
import (
"context"
"testing"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/stretchr/testify/require"
)
func TestUsers(t *testing.T) {
t.Parallel()
t.Run("Authenticated", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_, err := server.Client.User(context.Background(), "")
require.NoError(t, err)
})
t.Run("CreateMultipleInitial", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_, err := server.Client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
Email: "dummy@coder.com",
Username: "fake",
Password: "password",
})
require.Error(t, err)
})
t.Run("LoginNoEmail", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "hello@io.io",
Password: "wowie",
})
require.Error(t, err)
})
t.Run("LoginBadPassword", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user, err := server.Client.User(context.Background(), "")
require.NoError(t, err)
_, err = server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
Password: "bananas",
})
require.Error(t, err)
})
}