Commit b8d66d79 authored by Taylan Develioglu's avatar Taylan Develioglu
Browse files

Add support obtaining personal access tokens via SSH

Implements the feature requested in gitlab-org/gitlab#19672

This requires the internal api counterpart in gitlab-org/gitlab!36302 to
be merged first.

It can be used as follows:
```
censored@censored-VirtualBox:~/git/gitlab$ ssh git@gitlab-2004 personal_access_token
remote:
remote: ========================================================================
remote:
remote: Usage: personal_access_token <name> <scope1[,scope2,...]> [ttl_days]
remote:
remote: ========================================================================
remote:

censored@censored-VirtualBox:~/git/gitlab$ ssh git@gitlab-2004 personal_access_token newtoken read_api,read_repository 30
Token:   aAY1G3YPeemECgUvxuXY
Scopes:  read_api,read_repository
Expires: 2020-08-07
```
parent 4b1ee791
......@@ -3,6 +3,7 @@ cover.out
tmp/*
.idea
*.log
*.swp
/*.log*
authorized_keys.lock
.gitlab_shell_secret
......
......@@ -7,6 +7,7 @@ import (
"gitlab.com/gitlab-org/gitlab-shell/internal/command/discover"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/healthcheck"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/lfsauthenticate"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/personalaccesstoken"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/receivepack"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/shared/disallowedcommand"
......@@ -63,6 +64,8 @@ func buildShellCommand(args *commandargs.Shell, config *config.Config, readWrite
return &uploadpack.Command{Config: config, Args: args, ReadWriter: readWriter}
case commandargs.UploadArchive:
return &uploadarchive.Command{Config: config, Args: args, ReadWriter: readWriter}
case commandargs.PersonalAccessToken:
return &personalaccesstoken.Command{Config: config, Args: args, ReadWriter: readWriter}
}
return nil
......
......@@ -11,6 +11,7 @@ import (
"gitlab.com/gitlab-org/gitlab-shell/internal/command/discover"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/healthcheck"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/lfsauthenticate"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/personalaccesstoken"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/receivepack"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/shared/disallowedcommand"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/twofactorrecover"
......@@ -98,6 +99,12 @@ func TestNew(t *testing.T) {
arguments: []string{"key", "principal"},
expectedType: &authorizedprincipals.Command{},
},
{
desc: "it returns a PersonalAccessToken command",
executable: gitlabShellExec,
environment: buildEnv("personal_access_token"),
expectedType: &personalaccesstoken.Command{},
},
}
for _, tc := range testCases {
......
......@@ -9,12 +9,13 @@ import (
)
const (
Discover CommandType = "discover"
TwoFactorRecover CommandType = "2fa_recovery_codes"
LfsAuthenticate CommandType = "git-lfs-authenticate"
ReceivePack CommandType = "git-receive-pack"
UploadPack CommandType = "git-upload-pack"
UploadArchive CommandType = "git-upload-archive"
Discover CommandType = "discover"
TwoFactorRecover CommandType = "2fa_recovery_codes"
LfsAuthenticate CommandType = "git-lfs-authenticate"
ReceivePack CommandType = "git-receive-pack"
UploadPack CommandType = "git-upload-pack"
UploadArchive CommandType = "git-upload-archive"
PersonalAccessToken CommandType = "personal_access_token"
GitProtocolEnv = "GIT_PROTOCOL"
)
......
package personalaccesstoken
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter"
"gitlab.com/gitlab-org/gitlab-shell/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/personalaccesstoken"
)
const (
usageText = "Usage: personal_access_token <name> <scope1[,scope2,...]> [ttl_days]"
expiresDateFormat = "2006-01-02"
)
type Command struct {
Config *config.Config
Args *commandargs.Shell
ReadWriter *readwriter.ReadWriter
TokenArgs *tokenArgs
}
type tokenArgs struct {
Name string
Scopes []string
ExpiresDate string // Calculated, a TTL is passed from command-line.
}
func (c *Command) Execute() error {
err := c.parseTokenArgs()
if err != nil {
return err
}
response, err := c.getPersonalAccessToken()
if err != nil {
return err
}
fmt.Fprint(c.ReadWriter.Out, "Token: "+response.Token+"\n")
fmt.Fprint(c.ReadWriter.Out, "Scopes: "+strings.Join(response.Scopes, ",")+"\n")
if response.ExpiresAt == "" {
fmt.Fprint(c.ReadWriter.Out, "Expires: never\n")
} else {
fmt.Fprint(c.ReadWriter.Out, "Expires: "+response.ExpiresAt+"\n")
}
return nil
}
func (c *Command) parseTokenArgs() error {
if len(c.Args.SshArgs) < 3 || len(c.Args.SshArgs) > 4 {
return errors.New(usageText)
}
c.TokenArgs = &tokenArgs{
Name: c.Args.SshArgs[1],
Scopes: strings.Split(c.Args.SshArgs[2], ","),
}
if len(c.Args.SshArgs) < 4 {
return nil
}
rawTTL := c.Args.SshArgs[3]
TTL, err := strconv.Atoi(rawTTL)
if err != nil || TTL < 0 {
return fmt.Errorf("Invalid value for days_ttl: '%s'", rawTTL)
}
c.TokenArgs.ExpiresDate = time.Now().AddDate(0, 0, TTL+1).Format(expiresDateFormat)
return nil
}
func (c *Command) getPersonalAccessToken() (*personalaccesstoken.Response, error) {
client, err := personalaccesstoken.NewClient(c.Config)
if err != nil {
return nil, err
}
return client.GetPersonalAccessToken(c.Args, c.TokenArgs.Name, &c.TokenArgs.Scopes, c.TokenArgs.ExpiresDate)
}
package personalaccesstoken
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-shell/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter"
"gitlab.com/gitlab-org/gitlab-shell/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/personalaccesstoken"
)
var (
requests []testserver.TestRequestHandler
)
func setup(t *testing.T) {
requests = []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/personal_access_token",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := ioutil.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *personalaccesstoken.RequestBody
json.Unmarshal(b, &requestBody)
switch requestBody.KeyId {
case "forbidden":
body := map[string]interface{}{
"success": false,
"message": "Forbidden!",
}
json.NewEncoder(w).Encode(body)
case "broken":
w.WriteHeader(http.StatusInternalServerError)
case "badresponse":
default:
var expiresAt interface{}
if requestBody.ExpiresAt == "" {
expiresAt = nil
} else {
expiresAt = "9001-11-17"
}
body := map[string]interface{}{
"success": true,
"token": "YXuxvUgCEmeePY3G1YAa",
"scopes": requestBody.Scopes,
"expires_at": expiresAt,
}
json.NewEncoder(w).Encode(body)
}
},
},
}
}
const (
cmdname = "personal_access_token"
)
func TestExecute(t *testing.T) {
setup(t)
url, cleanup := testserver.StartSocketHttpServer(t, requests)
defer cleanup()
testCases := []struct {
desc string
arguments *commandargs.Shell
expectedOutput string
expectedError string
}{
{
desc: "Without any arguments",
arguments: &commandargs.Shell{},
expectedError: usageText,
},
{
desc: "With too few arguments",
arguments: &commandargs.Shell{
SshArgs: []string{cmdname, "newtoken"},
},
expectedError: usageText,
},
{
desc: "With too many arguments",
arguments: &commandargs.Shell{
SshArgs: []string{cmdname, "newtoken", "api", "bad_ttl", "toomany"},
},
expectedError: usageText,
},
{
desc: "With a bad ttl_days argument",
arguments: &commandargs.Shell{
SshArgs: []string{cmdname, "newtoken", "api", "bad_ttl"},
},
expectedError: "Invalid value for days_ttl: 'bad_ttl'",
},
{
desc: "Without a ttl argument",
arguments: &commandargs.Shell{
GitlabKeyId: "default",
SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"},
},
expectedOutput: "Token: YXuxvUgCEmeePY3G1YAa\n" +
"Scopes: read_api,read_repository\n" +
"Expires: never\n",
},
{
desc: "With a ttl argument",
arguments: &commandargs.Shell{
GitlabKeyId: "default",
SshArgs: []string{cmdname, "newtoken", "api", "30"},
},
expectedOutput: "Token: YXuxvUgCEmeePY3G1YAa\n" +
"Scopes: api\n" +
"Expires: 9001-11-17\n",
},
{
desc: "With bad response",
arguments: &commandargs.Shell{
GitlabKeyId: "badresponse",
SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"},
},
expectedError: "Parsing failed",
},
{
desc: "when API returns an error",
arguments: &commandargs.Shell{
GitlabKeyId: "forbidden",
SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"},
},
expectedError: "Forbidden!",
},
{
desc: "When API fails",
arguments: &commandargs.Shell{
GitlabKeyId: "broken",
SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"},
},
expectedError: "Internal API error (500)",
},
{
desc: "Without KeyID or User",
arguments: &commandargs.Shell{
SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"},
},
expectedError: "who='' is invalid",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
output := &bytes.Buffer{}
input := bytes.NewBufferString("")
cmd := &Command{
Config: &config.Config{GitlabUrl: url},
Args: tc.arguments,
ReadWriter: &readwriter.ReadWriter{Out: output, In: input},
}
err := cmd.Execute()
if tc.expectedError == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.expectedError)
}
if tc.expectedOutput != "" {
assert.Equal(t, tc.expectedOutput, output.String())
}
})
}
}
package personalaccesstoken
import (
"errors"
"fmt"
"net/http"
"gitlab.com/gitlab-org/gitlab-shell/client"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet"
"gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/discover"
)
type Client struct {
config *config.Config
client *client.GitlabNetClient
}
type Response struct {
Success bool `json:"success"`
Token string `json:"token"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
Message string `json:"message"`
}
type RequestBody struct {
KeyId string `json:"key_id,omitempty"`
UserId int64 `json:"user_id,omitempty"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at,omitempty"`
}
func NewClient(config *config.Config) (*Client, error) {
client, err := gitlabnet.GetClient(config)
if err != nil {
return nil, fmt.Errorf("Error creating http client: %v", err)
}
return &Client{config: config, client: client}, nil
}
func (c *Client) GetPersonalAccessToken(args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*Response, error) {
requestBody, err := c.getRequestBody(args, name, scopes, expiresAt)
if err != nil {
return nil, err
}
response, err := c.client.Post("/personal_access_token", requestBody)
if err != nil {
return nil, err
}
defer response.Body.Close()
return parse(response)
}
func parse(hr *http.Response) (*Response, error) {
response := &Response{}
if err := gitlabnet.ParseJSON(hr, response); err != nil {
return nil, err
}
if !response.Success {
return nil, errors.New(response.Message)
}
return response, nil
}
func (c *Client) getRequestBody(args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*RequestBody, error) {
client, err := discover.NewClient(c.config)
if err != nil {
return nil, err
}
requestBody := &RequestBody{Name: name, Scopes: *scopes, ExpiresAt: expiresAt}
if args.GitlabKeyId != "" {
requestBody.KeyId = args.GitlabKeyId
return requestBody, nil
}
userInfo, err := client.GetByCommandArgs(args)
if err != nil {
return nil, err
}
requestBody.UserId = userInfo.UserId
return requestBody, nil
}
package personalaccesstoken
import (
"encoding/json"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-shell/client"
"gitlab.com/gitlab-org/gitlab-shell/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/discover"
)
var (
requests []testserver.TestRequestHandler
)
func initialize(t *testing.T) {
requests = []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/personal_access_token",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := ioutil.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *RequestBody
json.Unmarshal(b, &requestBody)
switch requestBody.KeyId {
case "0":
body := map[string]interface{}{
"success": true,
"token": "aAY1G3YPeemECgUvxuXY",
"scopes": [2]string{"read_api", "read_repository"},
"expires_at": "9001-11-17",
}
json.NewEncoder(w).Encode(body)
case "1":
body := map[string]interface{}{
"success": false,
"message": "missing user",
}
json.NewEncoder(w).Encode(body)
case "2":
w.WriteHeader(http.StatusForbidden)
body := &client.ErrorResponse{
Message: "Not allowed!",
}
json.NewEncoder(w).Encode(body)
case "3":
w.Write([]byte("{ \"message\": \"broken json!\""))
case "4":
w.WriteHeader(http.StatusForbidden)
}
if requestBody.UserId == 1 {
body := map[string]interface{}{
"success": true,
"token": "YXuxvUgCEmeePY3G1YAa",
"scopes": [1]string{"api"},
"expires_at": nil,
}
json.NewEncoder(w).Encode(body)
}
},
},
{
Path: "/api/v4/internal/discover",
Handler: func(w http.ResponseWriter, r *http.Request) {
body := &discover.Response{
UserId: 1,
Username: "jane-doe",
Name: "Jane Doe",
}
json.NewEncoder(w).Encode(body)
},
},
}
}
func TestGetPersonalAccessTokenByKeyId(t *testing.T) {
client, cleanup := setup(t)
defer cleanup()
args := &commandargs.Shell{GitlabKeyId: "0"}
result, err := client.GetPersonalAccessToken(
args, "newtoken", &[]string{"read_api", "read_repository"}, "",
)
assert.NoError(t, err)
response := &Response{
true,
"aAY1G3YPeemECgUvxuXY",
[]string{"read_api", "read_repository"},
"9001-11-17",
"",
}
assert.Equal(t, response, result)
}
func TestGetRecoveryCodesByUsername(t *testing.T) {
client, cleanup := setup(t)
defer cleanup()
args := &commandargs.Shell{GitlabUsername: "jane-doe"}
result, err := client.GetPersonalAccessToken(
args, "newtoken", &[]string{"api"}, "",
)
assert.NoError(t, err)
response := &Response{true, "YXuxvUgCEmeePY3G1YAa", []string{"api"}, "", ""}
assert.Equal(t, response, result)
}
func TestMissingUser(t *testing.T) {
client, cleanup := setup(t)
defer cleanup()
args := &commandargs.Shell{GitlabKeyId: "1"}
_, err := client.GetPersonalAccessToken(
args, "newtoken", &[]string{"api"}, "",
)
assert.Equal(t, "missing user", err.Error())
}
func TestErrorResponses(t *testing.T) {
client, cleanup := setup(t)
defer cleanup()
testCases := []struct {
desc string
fakeId string
expectedError string
}{
{
desc: "A response with an error message",
fakeId: "2",
expectedError: "Not allowed!",
},
{
desc: "A response with bad JSON",
fakeId: "3",
expectedError: "Parsing failed",
},
{
desc: "An error response without message",
fakeId: "4",
expectedError: "Internal API error (403)",
},
}
for _, tc := range testCases {