~shulhan/karajo

f5596c9eef4c85350a9464ccdfdd7de40d6a64da — Shulhan 1 year, 7 months ago 8db3aab
all: implement HTTP API for authentication with user's name and password

The API for auth login, /karajo/api/auth/login, authenticate user using
name and password.
A valid user's account will receive authorization cookie named `karajo`
that can be used as authorization for subsequent request.
4 files changed, 243 insertions(+), 0 deletions(-)

M karajo.go
M karajo_httpd.go
A karajo_httpd_test.go
A karajo_session.go
M karajo.go => karajo.go +2 -0
@@ 50,6 50,7 @@ var (
type Karajo struct {
	httpd *libhttp.Server
	env   *Environment
	sm    *sessionManager
}

// Sign generate hex string of HMAC + SHA256 of payload using the secret.


@@ 75,6 76,7 @@ func New(env *Environment) (k *Karajo, err error) {

	k = &Karajo{
		env: env,
		sm:  newSessionManager(),
	}

	mlog.SetPrefix(env.Name + `:`)

M karajo_httpd.go => karajo_httpd.go +84 -0
@@ 14,6 14,7 @@ import (
	"strings"
	"time"

	liberrors "github.com/shuLhan/share/lib/errors"
	libhttp "github.com/shuLhan/share/lib/http"
)



@@ 22,6 23,8 @@ const HeaderNameXKarajoSign = `X-Karajo-Sign`

// List of HTTP API.
const (
	apiAuthLogin = `/karajo/api/auth/login`

	apiEnvironment = `/karajo/api/environment`

	apiJobHttp       = `/karajo/api/job_http`


@@ 40,6 43,17 @@ const (
	paramNameCounter     = `counter`
	paramNameID          = `id`
	paramNameKarajoEpoch = `_karajo_epoch`
	paramNameName        = `name`
	paramNamePassword    = `password`
)

// List of errors related to HTTP APIs.
var (
	errAuthLogin = liberrors.E{
		Code:    http.StatusBadRequest,
		Name:    `ERR_AUTH_LOGIN`,
		Message: `invalid user name and/or password`,
	}
)

// initHttpd initialize the HTTP server, including registering its endpoints


@@ 82,6 96,17 @@ func (k *Karajo) registerApis() (err error) {
	var logp = `registerApis`

	err = k.httpd.RegisterEndpoint(&libhttp.Endpoint{
		Method:       libhttp.RequestMethodPost,
		Path:         apiAuthLogin,
		RequestType:  libhttp.RequestTypeForm,
		ResponseType: libhttp.ResponseTypeJSON,
		Call:         k.apiAuthLogin,
	})
	if err != nil {
		return fmt.Errorf(`%s: %w`, logp, err)
	}

	err = k.httpd.RegisterEndpoint(&libhttp.Endpoint{
		Method:       libhttp.RequestMethodGet,
		Path:         apiEnvironment,
		RequestType:  libhttp.RequestTypeNone,


@@ 192,6 217,65 @@ func (k *Karajo) registerJobsHook() (err error) {
	return nil
}

// apiAuthLogin authenticate user using name and password.
//
// A valid user's account will receive authorization cookie named `karajo`
// that can be used as authorization for subsequent request.
//
// Request format,
//
//	POST /karajo/api/auth/login
//	Content-Type: application/x-www-form-urlencoded
//
//	name=&password=
//
// List of response,
//
//   - 200 OK: success.
//   - 400 ERR_AUTH_LOGIN: invalid name and/or password.
//   - 500 ERR_INTERNAL: internal server error.
func (k *Karajo) apiAuthLogin(epr *libhttp.EndpointRequest) (respBody []byte, err error) {
	var (
		logp = `apiAuthLogin`
		name = epr.HttpRequest.Form.Get(paramNameName)
		pass = epr.HttpRequest.Form.Get(paramNamePassword)
	)

	name = strings.TrimSpace(name)
	if len(name) == 0 {
		return nil, &errAuthLogin
	}

	pass = strings.TrimSpace(pass)
	if len(pass) == 0 {
		return nil, &errAuthLogin
	}

	var user = k.env.Users[name]
	if user == nil {
		return nil, &errAuthLogin
	}

	if !user.authenticate(pass) {
		return nil, &errAuthLogin
	}

	err = k.sessionNew(epr.HttpWriter, user)
	if err != nil {
		return nil, fmt.Errorf(`%s: %w`, logp, err)
	}

	var res = &libhttp.EndpointResponse{}

	res.Code = http.StatusOK
	respBody, err = json.Marshal(res)
	if err != nil {
		return nil, fmt.Errorf(`%s: %w`, logp, err)
	}

	return respBody, nil
}

func (k *Karajo) apiEnvironment(epr *libhttp.EndpointRequest) (resbody []byte, err error) {
	var (
		logp = `apiEnvironment`

A karajo_httpd_test.go => karajo_httpd_test.go +118 -0
@@ 0,0 1,118 @@
// SPDX-FileCopyrightText: 2023 M. Shulhan <ms@kilabit.info>
// SPDX-License-Identifier: GPL-3.0-or-later

package karajo

import (
	"math/rand"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"testing"

	libhttp "github.com/shuLhan/share/lib/http"
	"github.com/shuLhan/share/lib/test"
)

func TestKarajo_apiAuthLogin(t *testing.T) {
	rand.Seed(42)

	var (
		user = &User{
			Name:     `tester`,
			Password: `$2a$10$9XMRfqpnzY2421fwYm5dd.CidJf7dHHWIESeeNGXuajHRf.Lqzy7a`, // s3cret

		}
		env = &Environment{
			Users: map[string]*User{
				user.Name: user,
			},
		}
		k = &Karajo{
			env: env,
			sm:  newSessionManager(),
		}
	)

	type testCase struct {
		expSM        *sessionManager
		desc         string
		name         string
		pass         string
		expError     string
		expResBody   string
		expResHeader string
	}

	var cases = []testCase{{
		desc:     `with empty name and password`,
		expError: errAuthLogin.Error(),
	}, {
		desc:     `with empty password`,
		name:     user.Name,
		expError: errAuthLogin.Message,
	}, {
		desc:     `with unknown user name`,
		name:     `unknown`,
		pass:     `notempty`,
		expError: errAuthLogin.Message,
	}, {
		desc:     `with invalid password`,
		name:     user.Name,
		pass:     `invalid`,
		expError: errAuthLogin.Message,
	}, {
		desc:       `with valid name and password`,
		name:       user.Name,
		pass:       `s3cret`,
		expResBody: `{"code":200}`,
		expResHeader: "HTTP/1.1 200 OK\r\n" +
			"Connection: close\r\n" +
			"Set-Cookie: karajo=ASG5Ohg1l0CMEefBrPrV9QtazJoL6uax; Path=/; Max-Age=86400; HttpOnly\r\n\r\n",
		expSM: &sessionManager{
			value: map[string]*User{
				`ASG5Ohg1l0CMEefBrPrV9QtazJoL6uax`: user,
			},
		},
	}}

	var (
		testRecorder = httptest.NewRecorder()
		epr          = &libhttp.EndpointRequest{
			HttpWriter: testRecorder,
			HttpRequest: &http.Request{
				Form: url.Values{},
			},
		}

		c        testCase
		respBody []byte
		rawResp  []byte
		err      error
		httpResp *http.Response
	)

	for _, c = range cases {
		epr.HttpRequest.Form.Set(`name`, c.name)
		epr.HttpRequest.Form.Set(`password`, c.pass)

		respBody, err = k.apiAuthLogin(epr)
		if err != nil {
			test.Assert(t, c.desc+`: error`, c.expError, err.Error())
			continue
		}

		test.Assert(t, c.desc+`: respBody`, c.expResBody, string(respBody))

		httpResp = testRecorder.Result()
		rawResp, err = httputil.DumpResponse(httpResp, false)
		if err != nil {
			t.Fatal(err)
		}

		test.Assert(t, c.desc+`: response header`, c.expResHeader, string(rawResp))

		test.Assert(t, c.desc+`: session manager`, c.expSM, k.sm)
	}
}

A karajo_session.go => karajo_session.go +39 -0
@@ 0,0 1,39 @@
// SPDX-FileCopyrightText: 2023 M. Shulhan <ms@kilabit.info>
// SPDX-License-Identifier: GPL-3.0-or-later

package karajo

import (
	"fmt"
	"net/http"
)

const (
	cookieName = `karajo`
)

// sessionNew generate and store new session for user.
func (k *Karajo) sessionNew(w http.ResponseWriter, user *User) (err error) {
	var (
		logp = `sessionNew`
		key  string
	)

	key = k.sm.new(user)
	if len(key) == 0 {
		return fmt.Errorf(`%s: failed to generate new session`, logp)
	}

	var cookie = &http.Cookie{
		Name:     cookieName,
		Value:    key,
		MaxAge:   86400, // One day in seconds.
		Path:     `/`,
		Secure:   false,
		HttpOnly: true,
	}

	http.SetCookie(w, cookie)

	return nil
}