From 9d63cfde73eb0991b972ca95d958fa1002624600 Mon Sep 17 00:00:00 2001 From: jay-dee7 Date: Wed, 27 Dec 2023 01:30:35 +0530 Subject: [PATCH] feat: Auth tokens for client-side usage Signed-off-by: jay-dee7 --- Makefile | 3 + api/users/users.go | 118 ++++++++++++++++++++++++++++++++++ auth/basic_auth.go | 10 +++ auth/bcrypt.go | 16 ++++- auth/permissions.go | 6 +- auth/user.go | 2 +- auth/validate_user.go | 25 +++++++ cmd/migrations/migrations.go | 8 +++ go.mod | 1 + go.sum | 3 + router/users.go | 6 +- scripts/load_dummy_users.sh | 14 ++++ store/v1/types/auth_tokens.go | 60 +++++++++++++++++ store/v1/types/users.go | 16 +++++ store/v1/users/store.go | 3 + store/v1/users/users_impl.go | 50 ++++++++++++++ 16 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 scripts/load_dummy_users.sh create mode 100644 store/v1/types/auth_tokens.go diff --git a/Makefile b/Makefile index 80732244..fead562a 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,9 @@ certs: openssl req -x509 -newkey rsa:4096 -keyout .certs/registry.local -out .certs/registry.local.crt -sha256 -days 365 \ -subj "/C=US/ST=Oregon/L=Portland/O=Company Name/OU=Org/CN=registry.local" -nodes +load_dummy_users: + sh ./scripts/load_dummy_users.sh + reset: psql -c 'drop database open_registry' && \ psql -c 'drop role open_registry_user' && \ diff --git a/api/users/users.go b/api/users/users.go index 3887842f..157d4735 100644 --- a/api/users/users.go +++ b/api/users/users.go @@ -1,8 +1,11 @@ package users import ( + "fmt" "net/http" + "time" + "github.com/containerish/OpenRegistry/auth" "github.com/containerish/OpenRegistry/store/v1/types" "github.com/containerish/OpenRegistry/store/v1/users" "github.com/containerish/OpenRegistry/telemetry" @@ -12,6 +15,8 @@ import ( type ( UserApi interface { SearchUsers(echo.Context) error + CreateUserToken(ctx echo.Context) error + ListUserToken(ctx echo.Context) error } api struct { @@ -47,3 +52,116 @@ func (a *api) SearchUsers(ctx echo.Context) error { a.logger.Log(ctx, nil).Send() return echoErr } + +func (a *api) CreateUserToken(ctx echo.Context) error { + ctx.Set(types.HandlerStartTime, time.Now()) + + user, ok := ctx.Get(string(types.UserContextKey)).(*types.User) + if !ok { + err := fmt.Errorf("missing authentication credentials") + echoErr := ctx.JSON(http.StatusUnauthorized, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + var body types.CreateAuthTokenRequest + if err := ctx.Bind(&body); err != nil { + echoErr := ctx.JSON(http.StatusBadRequest, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + if body.Name == "" { + err := fmt.Errorf("token name is a required field") + echoErr := ctx.JSON(http.StatusBadRequest, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + token, err := types.CreateNewAuthToken() + if err != nil { + echoErr := ctx.JSON(http.StatusInternalServerError, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + hashedToken, err := auth.GenerateSafeHash([]byte(token.RawString())) + if err != nil { + echoErr := ctx.JSON(http.StatusInternalServerError, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + authToken := &types.AuthTokens{ + CreatedAt: time.Now(), + ExpiresAt: body.ExpiresAt, + Name: body.Name, + AuthToken: hashedToken, + OwnerID: user.ID, + } + + if err = a.userStore.AddAuthToken(ctx.Request().Context(), authToken); err != nil { + echoErr := ctx.JSON(http.StatusInternalServerError, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + echoErr := ctx.JSON(http.StatusOK, echo.Map{ + "token": token.String(), + }) + + a.logger.Log(ctx, nil).Str("client_token", token.String()).Str("stored_token", hashedToken).Send() + return echoErr +} + +func (a *api) ListUserToken(ctx echo.Context) error { + ctx.Set(types.HandlerStartTime, time.Now()) + + user, ok := ctx.Get(string(types.UserContextKey)).(*types.User) + if !ok { + err := fmt.Errorf("missing authentication credentials") + echoErr := ctx.JSON(http.StatusUnauthorized, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + tokens, err := a.userStore.ListAuthTokens(ctx.Request().Context(), user.ID) + if err != nil { + echoErr := ctx.JSON(http.StatusInternalServerError, echo.Map{ + "error": err.Error(), + }) + + a.logger.Log(ctx, err).Send() + return echoErr + } + + if len(tokens) == 0 { + tokens = make([]*types.AuthTokens, 0) + } + + echoErr := ctx.JSON(http.StatusOK, tokens) + + a.logger.Log(ctx, nil).Send() + return echoErr +} diff --git a/auth/basic_auth.go b/auth/basic_auth.go index 2e1d7369..1921f1c1 100644 --- a/auth/basic_auth.go +++ b/auth/basic_auth.go @@ -172,6 +172,16 @@ func (a *auth) validateBasicAuthCredentials(auth string) (*types.User, error) { return user, nil } + + if strings.HasPrefix(password, types.OpenRegistryAuthTokenPrefix) { + user, err := a.validateUserWithPAT(context.Background(), username, password) + if err != nil { + return nil, err + } + + return user, nil + } + user, err := a.validateUser(username, password) if err != nil { return nil, err diff --git a/auth/bcrypt.go b/auth/bcrypt.go index c6e6b430..7dc2cd0c 100644 --- a/auth/bcrypt.go +++ b/auth/bcrypt.go @@ -1,17 +1,20 @@ package auth import ( + "crypto/sha256" + "fmt" + "golang.org/x/crypto/bcrypt" ) -const bcryptMinCost = 6 +const BcryptMinCost = 6 func (a *auth) hashPassword(password string) (string, error) { // Convert password string to byte slice var passwordBytes = []byte(password) // Hash password with Bcrypt's min cost - hashedPasswordBytes, err := bcrypt.GenerateFromPassword(passwordBytes, bcryptMinCost) + hashedPasswordBytes, err := bcrypt.GenerateFromPassword(passwordBytes, BcryptMinCost) return string(hashedPasswordBytes), err } @@ -20,3 +23,12 @@ func (a *auth) verifyPassword(hashedPassword, currPassword string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(currPassword)) return err == nil } + +func GenerateSafeHash(input []byte) (string, error) { + hash := sha256.New() + if n, err := hash.Write(input); err != nil || n != len(input) { + return "", fmt.Errorf("error generating hash") + } + + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} diff --git a/auth/permissions.go b/auth/permissions.go index 378d666d..ce1b6177 100644 --- a/auth/permissions.go +++ b/auth/permissions.go @@ -129,10 +129,8 @@ func (a *auth) handleTokenRequest(ctx echo.Context, handler echo.HandlerFunc) (e if err := a.populateUserFromPermissionsCheck(ctx); err != nil { registryErr := common.RegistryErrorResponse( registry.RegistryErrorCodeUnauthorized, - "missing user credentials in request", - echo.Map{ - "error": err.Error(), - }, + err.Error(), + nil, ) echoErr := ctx.JSONBlob(http.StatusUnauthorized, registryErr.Bytes()) diff --git a/auth/user.go b/auth/user.go index dddb555c..d7f6b97e 100644 --- a/auth/user.go +++ b/auth/user.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/containerish/OpenRegistry/types" + "github.com/containerish/OpenRegistry/store/v1/types" "github.com/labstack/echo/v4" ) diff --git a/auth/validate_user.go b/auth/validate_user.go index 0d4eceb1..26400f01 100644 --- a/auth/validate_user.go +++ b/auth/validate_user.go @@ -28,3 +28,28 @@ func (a *auth) validateUser(username, password string) (*types.User, error) { return user, nil } + +func (a *auth) validateUserWithPAT(ctx context.Context, username, authToken string) (*types.User, error) { + user, err := a.userStore.GetUserByUsername(ctx, username) + if err != nil { + return nil, err + } + + token, err := (&types.AuthToken{}).FromString(authToken) + if err != nil { + return nil, fmt.Errorf("ERR_PARSE_AUTH_TOKEN: %w", err) + } + + hashedToken, err := GenerateSafeHash([]byte(token.RawString())) + if err != nil { + return nil, err + } + + _, err = a.userStore.GetAuthToken(ctx, user.ID, hashedToken) + if err != nil { + return nil, err + } + + return user, nil + +} diff --git a/cmd/migrations/migrations.go b/cmd/migrations/migrations.go index 7e20a2ac..734b7a52 100644 --- a/cmd/migrations/migrations.go +++ b/cmd/migrations/migrations.go @@ -156,6 +156,14 @@ func createOpenRegistryTables(ctx *cli.Context, db *bun.DB) error { } color.Green(`Table "permissions" created ✔︎`) + _, err = db.NewCreateTable().Model(&types.AuthTokens{}).Table().IfNotExists().Exec(ctx.Context) + if err != nil { + return errors.New( + color.RedString("Table=auth_tokens Created=❌ Error=%s", err), + ) + } + color.Green(`Table "auth_tokens" created ✔︎`) + return nil } diff --git a/go.mod b/go.mod index b6ced963..e7f40d93 100644 --- a/go.mod +++ b/go.mod @@ -158,6 +158,7 @@ require ( github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-multistream v0.5.0 // indirect github.com/multiformats/go-varint v0.0.7 // indirect + github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index b1758a03..6d3e2b7f 100644 --- a/go.sum +++ b/go.sum @@ -575,6 +575,8 @@ github.com/multiformats/go-varint v0.0.1/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXS github.com/multiformats/go-varint v0.0.5/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -589,6 +591,7 @@ github.com/openzipkin/zipkin-go v0.4.1 h1:kNd/ST2yLLWhaWrkgchya40TJabe8Hioj9udfP github.com/openzipkin/zipkin-go v0.4.1/go.mod h1:qY0VqDSN1pOBN94dBc6w2GJlWLiovAyg7Qt6/I9HecM= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= diff --git a/router/users.go b/router/users.go index e8595753..487252ad 100644 --- a/router/users.go +++ b/router/users.go @@ -7,6 +7,8 @@ import ( "github.com/labstack/echo/v4" ) -func RegisterUserRoutes(router *echo.Group, api users.UserApi) { - router.Add(http.MethodGet, "/search", api.SearchUsers) +func RegisterUserRoutes(router *echo.Group, api users.UserApi, middlewares ...echo.MiddlewareFunc) { + router.Add(http.MethodGet, "/search", api.SearchUsers, middlewares...) + router.Add(http.MethodPost, "/token", api.CreateUserToken, middlewares...) + router.Add(http.MethodGet, "/token", api.ListUserToken, middlewares...) } diff --git a/scripts/load_dummy_users.sh b/scripts/load_dummy_users.sh new file mode 100644 index 00000000..83325c8e --- /dev/null +++ b/scripts/load_dummy_users.sh @@ -0,0 +1,14 @@ +#!/bin/sh + + curl -sSL -XPOST -d '{"email": "me@example.com", "username": "johndoe", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "asta@example.com", "username": "asta", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "yuno@example.com", "username": "yuno", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "noelle@example.com", "username": "noelle", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "mimosa@example.com", "username": "mimosa", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "klaus@example.com", "username": "klaus", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "finral@example.com", "username": "finral", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "yami@example.com", "username": "yami", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "vangeance@example.com", "username": "vangeance", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "leopold@example.com", "username": "leopold", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "vanessa@example.com", "username": "vanessa", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq + curl -sSL -XPOST -d '{"email": "charmy@example.com", "username": "charmy", "password": "Qwerty@123"}' 'http://localhost:5000/auth/signup' | jq diff --git a/store/v1/types/auth_tokens.go b/store/v1/types/auth_tokens.go new file mode 100644 index 00000000..483e9494 --- /dev/null +++ b/store/v1/types/auth_tokens.go @@ -0,0 +1,60 @@ +package types + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "github.com/oklog/ulid/v2" +) + +type AuthToken struct { + id ulid.ULID +} + +const ( + OpenRegistryAuthTokenPrefix = "oreg_pat_" +) + +func (at *AuthToken) String() string { + return fmt.Sprintf("%s%s", OpenRegistryAuthTokenPrefix, at.id.String()) +} + +func (at *AuthToken) Bytes() []byte { + return at.id.Bytes() +} + +func (at *AuthToken) Compare(other ulid.ULID) int { + return at.id.Compare(other) +} + +func (at *AuthToken) FromString(token string) (*AuthToken, error) { + token = strings.TrimPrefix(token, OpenRegistryAuthTokenPrefix) + + id, err := ulid.Parse(token) + if err != nil { + return nil, err + } + + return &AuthToken{id: id}, nil +} + +func (at *AuthToken) RawString() string { + return at.id.String() +} + +func CreateNewAuthToken() (*AuthToken, error) { + now := time.Now() + entropy := rand.New(rand.NewSource(now.UnixNano())) + ms := ulid.Timestamp(now) + + id, err := ulid.New(ms, entropy) + if err != nil { + return nil, err + } + + return &AuthToken{ + id: id, + }, nil +} diff --git a/store/v1/types/users.go b/store/v1/types/users.go index a19e99f4..ed345b2e 100644 --- a/store/v1/types/users.go +++ b/store/v1/types/users.go @@ -58,6 +58,7 @@ type ( Permissions []*Permissions `bun:"rel:has-many,join:id=user_id" json:"permissions"` Repositories []*ContainerImageRepository `bun:"rel:has-many,join:id=owner_id" json:"-"` Projects []*RepositoryBuildProject `bun:"rel:has-many,join:id=repository_owner_id" json:"-"` + AuthTokens []*AuthTokens `bun:"rel:has-many,join:id=owner_id" json:"-"` // nolint:lll FavoriteRepositories []uuid.UUID `bun:"favorite_repositories,type:uuid[],default:'{}'" json:"favorite_repositories"` ID uuid.UUID `bun:"id,type:uuid,pk" json:"id,omitempty" validate:"-"` @@ -67,6 +68,16 @@ type ( IsOrgOwner bool `bun:"is_org_owner" json:"is_org_owner,omitempty"` } + AuthTokens struct { + bun.BaseModel `bun:"table:auth_tokens,alias:s" json:"-"` + + Name string `bun:"name" json:"name"` + CreatedAt time.Time `bun:"created_at" json:"created_at,omitempty" validate:"-"` + ExpiresAt time.Time `bun:"expires_at" json:"expires_at,omitempty" validate:"-"` + OwnerID uuid.UUID `bun:"owner_id,type:uuid" json:"-"` + AuthToken string `bun:"auth_token,type:text,pk" json:"-"` + } + // type here is string so that we can use it with echo.Context & std context.Context ContextKey string @@ -95,6 +106,11 @@ type ( Token uuid.UUID `bun:"token,pk,type:uuid" json:"-"` UserId uuid.UUID `bun:"user_id,type:uuid" json:"-"` } + + CreateAuthTokenRequest struct { + ExpiresAt time.Time `json:"expires_at"` + Name string `json:"name"` + } ) const ( diff --git a/store/v1/users/store.go b/store/v1/users/store.go index d1ff9270..aa2d1055 100644 --- a/store/v1/users/store.go +++ b/store/v1/users/store.go @@ -43,6 +43,9 @@ type UserReader interface { Search(ctx context.Context, query string) ([]*types.User, error) GetOrgUsersByOrgID(ctx context.Context, orgID uuid.UUID) ([]*types.Permissions, error) MatchUserType(ctx context.Context, userType types.UserType, userIds ...uuid.UUID) bool + AddAuthToken(ctx context.Context, token *types.AuthTokens) error + ListAuthTokens(ctx context.Context, ownerID uuid.UUID) ([]*types.AuthTokens, error) + GetAuthToken(ctx context.Context, ownerID uuid.UUID, hashedToken string) (*types.AuthTokens, error) } type UserGetter interface { diff --git a/store/v1/users/users_impl.go b/store/v1/users/users_impl.go index 615031cb..9bf23efa 100644 --- a/store/v1/users/users_impl.go +++ b/store/v1/users/users_impl.go @@ -3,7 +3,9 @@ package users import ( "context" "database/sql" + "fmt" "strings" + "time" v1 "github.com/containerish/OpenRegistry/store/v1" "github.com/containerish/OpenRegistry/store/v1/types" @@ -321,3 +323,51 @@ func (us *userStore) MatchUserType(ctx context.Context, userType types.UserType, return len(userIds) == count } + +func (us *userStore) AddAuthToken(ctx context.Context, token *types.AuthTokens) error { + if token.ExpiresAt.IsZero() { + token.ExpiresAt = time.Now().AddDate(1, 0, 0) + } + + _, err := us.db.NewInsert().Model(token).Exec(ctx) + return err +} + +func (us *userStore) ListAuthTokens(ctx context.Context, ownerID uuid.UUID) ([]*types.AuthTokens, error) { + var tokens []*types.AuthTokens + + err := us. + db. + NewSelect(). + Model(&tokens). + ExcludeColumn("auth_token"). + ExcludeColumn("owner_id"). + Where("owner_id = ?", ownerID). + Scan(ctx) + if err != nil { + return nil, err + } + + return tokens, nil +} + +func (us *userStore) GetAuthToken(ctx context.Context, ownerID uuid.UUID, hashedToken string) (*types.AuthTokens, error) { + var token types.AuthTokens + + err := us. + db. + NewSelect(). + Model(&token). + Where("owner_id = ?", ownerID). + Where("auth_token = ?", hashedToken). + Scan(ctx) + if err != nil { + return nil, err + } + + if token.ExpiresAt.Unix() < time.Now().Unix() { + return nil, fmt.Errorf("Token has expired, please generate a new one") + } + + return &token, nil +}