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
+116
View File
@@ -0,0 +1,116 @@
package codersdk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"golang.org/x/xerrors"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// New creates a Coder client for the provided URL.
func New(url *url.URL) *Client {
return &Client{
url: url,
httpClient: &http.Client{},
}
}
// Client is an HTTP caller for methods to the Coder API.
type Client struct {
url *url.URL
httpClient *http.Client
}
// SetSessionToken applies the provided token to the current client.
func (c *Client) SetSessionToken(token string) error {
if c.httpClient.Jar == nil {
var err error
c.httpClient.Jar, err = cookiejar.New(nil)
if err != nil {
return err
}
}
c.httpClient.Jar.SetCookies(c.url, []*http.Cookie{{
Name: httpmw.AuthCookie,
Value: token,
}})
return nil
}
// request performs an HTTP request with the body provided.
// The caller is responsible for closing the response body.
func (c *Client) request(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
url, err := c.url.Parse(path)
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
var buf bytes.Buffer
if body != nil {
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
err = enc.Encode(body)
if err != nil {
return nil, xerrors.Errorf("encode body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, url.String(), &buf)
if err != nil {
return nil, xerrors.Errorf("create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, xerrors.Errorf("do: %w", err)
}
return resp, err
}
// readBodyAsError reads the response as an httpapi.Message, and
// wraps it in a codersdk.Error type for easy marshalling.
func readBodyAsError(res *http.Response) error {
var m httpapi.Response
err := json.NewDecoder(res.Body).Decode(&m)
if err != nil {
if errors.Is(err, io.EOF) {
// If no body is sent, we'll just provide the status code.
return &Error{
statusCode: res.StatusCode,
}
}
return xerrors.Errorf("decode body: %w", err)
}
return &Error{
Response: m,
statusCode: res.StatusCode,
}
}
// Error represents an unaccepted or invalid request to the API.
type Error struct {
httpapi.Response
statusCode int
}
func (e *Error) StatusCode() int {
return e.statusCode
}
func (e *Error) Error() string {
return fmt.Sprintf("status code %d: %s", e.statusCode, e.Message)
}
+59
View File
@@ -0,0 +1,59 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"github.com/coder/coder/coderd"
)
// CreateInitialUser attempts to create the first user on a Coder deployment.
// This initial user has superadmin privileges. If >0 users exist, this request
// will fail.
func (c *Client) CreateInitialUser(ctx context.Context, req coderd.CreateUserRequest) (coderd.User, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/user", req)
if err != nil {
return coderd.User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.User{}, readBodyAsError(res)
}
var user coderd.User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// User returns a user for the ID provided.
// If the ID string is empty, the current user will be returned.
func (c *Client) User(ctx context.Context, id string) (coderd.User, error) {
res, err := c.request(ctx, http.MethodGet, "/api/v2/user", nil)
if err != nil {
return coderd.User{}, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return coderd.User{}, readBodyAsError(res)
}
var user coderd.User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// LoginWithPassword creates a session token authenticating with an email and password.
// Call `SetSessionToken()` to apply the newly acquired token to the client.
func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPasswordRequest) (coderd.LoginWithPasswordResponse, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/login", req)
if err != nil {
return coderd.LoginWithPasswordResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.LoginWithPasswordResponse{}, readBodyAsError(res)
}
var resp coderd.LoginWithPasswordResponse
err = json.NewDecoder(res.Body).Decode(&resp)
if err != nil {
return coderd.LoginWithPasswordResponse{}, err
}
return resp, nil
}
+33
View File
@@ -0,0 +1,33 @@
package codersdk_test
import (
"context"
"net/http"
"testing"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/stretchr/testify/require"
)
func TestUsers(t *testing.T) {
t.Run("MultipleInitial", func(t *testing.T) {
server := coderdtest.New(t)
_, err := server.Client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
Email: "wowie@coder.com",
Username: "tester",
Password: "moo",
})
var cerr *codersdk.Error
require.ErrorAs(t, err, &cerr)
require.Equal(t, cerr.StatusCode(), http.StatusConflict)
require.Greater(t, len(cerr.Error()), 0)
})
t.Run("Get", func(t *testing.T) {
server := coderdtest.New(t)
_, err := server.Client.User(context.Background(), "")
require.NoError(t, err)
})
}