mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+5
-4
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user