Skip to content

Commit

Permalink
feat: support user access tokens
Browse files Browse the repository at this point in the history
Users now can generate access tokens and use them to authenticate with
Soft Serve HTTP Git server. It supports basic username & password,
generated access tokens, and JWT tokens.

As of now there is no way the user can set a password. This will be
implemented in a separate PR.

Access tokens hashes are stored in the database along with an optional
expiry date.

Access tokens can be used as the Git user or password in a HTTP clone
URL e.g. `https://<token>@git.example.com/repo.git`

fix: lint errors

fix: ensure default branch on http push

fix: address carlos comments
  • Loading branch information
aymanbagabas committed Aug 4, 2023
1 parent f69f064 commit a9e5ace
Show file tree
Hide file tree
Showing 22 changed files with 589 additions and 173 deletions.
8 changes: 2 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ require (
)

require (
github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305
github.com/caarlos0/env/v8 v8.0.0
github.com/caarlos0/tablewriter v0.1.0
github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
github.com/charmbracelet/keygen v0.4.3
github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
Expand Down Expand Up @@ -49,26 +51,21 @@ require (
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/caarlos0/sshmarshal v0.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/leonelquinteros/gotext v1.5.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand All @@ -81,7 +78,6 @@ require (
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
Expand Down
90 changes: 4 additions & 86 deletions go.sum

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions server/backend/access_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package backend

import (
"context"
"errors"
"time"

"github.com/charmbracelet/soft-serve/server/db"
"github.com/charmbracelet/soft-serve/server/proto"
)

// CreateAccessToken creates an access token for user.
func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) {
token := GenerateToken()
tokenHash := HashToken(token)

if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
_, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt)
if err != nil {
return db.WrapError(err)
}

return nil
}); err != nil {
return "", err
}

return token, nil
}

// DeleteAccessToken deletes an access token for a user.
func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error {
err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
_, err := b.store.GetAccessToken(ctx, tx, id)
if err != nil {
return db.WrapError(err)
}

if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil {
return db.WrapError(err)
}
return nil
})
if err != nil {
if errors.Is(err, db.ErrRecordNotFound) {
return proto.ErrTokenNotFound
}
return err
}

return nil
}

// ListAccessTokens lists access tokens for a user.
func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) {
accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID())
if err != nil {
return nil, db.WrapError(err)
}

var tokens []proto.AccessToken
for _, t := range accessTokens {
token := proto.AccessToken{
ID: t.ID,
Name: t.Name,
TokenHash: t.Token,
UserID: t.UserID,
CreatedAt: t.CreatedAt,
}
if t.ExpiresAt.Valid {
token.ExpiresAt = t.ExpiresAt.Time
}

tokens = append(tokens, token)
}

return tokens, nil
}
11 changes: 9 additions & 2 deletions server/backend/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package backend

import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"

"github.com/charmbracelet/log"
Expand All @@ -26,8 +27,8 @@ func VerifyPassword(password, hash string) bool {
return err == nil
}

// GenerateAccessToken returns a random unique token.
func GenerateAccessToken() string {
// GenerateToken returns a random unique token.
func GenerateToken() string {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
log.Error("unable to generate access token")
Expand All @@ -36,3 +37,9 @@ func GenerateAccessToken() string {

return "ss_" + hex.EncodeToString(buf)
}

// HashToken hashes the token using sha256.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token + saltySalt))
return hex.EncodeToString(sum[:])
}
51 changes: 51 additions & 0 deletions server/backend/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"

"github.com/charmbracelet/soft-serve/server/access"
"github.com/charmbracelet/soft-serve/server/db"
Expand Down Expand Up @@ -117,6 +118,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
if errors.Is(err, db.ErrRecordNotFound) {
return nil, proto.ErrUserNotFound
}
d.logger.Error("error finding user", "username", username, "error", err)
return nil, err
}

Expand Down Expand Up @@ -146,6 +148,46 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
if errors.Is(err, db.ErrRecordNotFound) {
return nil, proto.ErrUserNotFound
}
d.logger.Error("error finding user", "pk", sshutils.MarshalAuthorizedKey(pk), "error", err)
return nil, err
}

return &user{
user: m,
publicKeys: pks,
}, nil
}

// UserByAccessToken finds a user by access token.
// This also validates the token for expiration and returns proto.ErrTokenExpired.
func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) {
var m models.User
var pks []ssh.PublicKey
token = HashToken(token)

if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
t, err := d.store.GetAccessTokenByToken(ctx, tx, token)
if err != nil {
return db.WrapError(err)
}

if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) {
return proto.ErrTokenExpired
}

m, err = d.store.FindUserByAccessToken(ctx, tx, token)
if err != nil {
return db.WrapError(err)
}

pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
return err
}); err != nil {
err = db.WrapError(err)
if errors.Is(err, db.ErrRecordNotFound) {
return nil, proto.ErrUserNotFound
}
d.logger.Error("failed to find user by access token", "err", err, "token", token)
return nil, err
}

Expand Down Expand Up @@ -335,3 +377,12 @@ func (u *user) Username() string {
func (u *user) ID() int64 {
return u.user.ID
}

// Password implements proto.User.
func (u *user) Password() string {
if u.user.Password.Valid {
return u.user.Password.String
}

return ""
}
5 changes: 1 addition & 4 deletions server/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,12 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
return
}

var handler git.ServiceHandler
var counter *prometheus.CounterVec
service := git.Service(split[0])
switch service {
case git.UploadPackService:
handler = git.UploadPack
counter = uploadPackGitCounter
case git.UploadArchiveService:
handler = git.UploadArchive
counter = uploadArchiveGitCounter
default:
d.fatal(c, git.ErrInvalidRequest)
Expand Down Expand Up @@ -289,7 +286,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
Dir: filepath.Join(reposDir, repo),
}

if err := handler(ctx, cmd); err != nil {
if err := service.Handler(ctx, cmd); err != nil {
d.logger.Debugf("git: error handling request: %v", err)
d.fatal(c, err)
return
Expand Down
13 changes: 13 additions & 0 deletions server/proto/access_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package proto

import "time"

// AccessToken represents an access token.
type AccessToken struct {
ID int64
Name string
UserID int64
TokenHash string
ExpiresAt time.Time
CreatedAt time.Time
}
10 changes: 7 additions & 3 deletions server/proto/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ var (
ErrUnauthorized = errors.New("unauthorized")
// ErrFileNotFound is returned when the file is not found.
ErrFileNotFound = errors.New("file not found")
// ErrRepoNotFound is returned when a repository does not exist.
// ErrRepoNotFound is returned when a repository is not found.
ErrRepoNotFound = errors.New("repository not found")
// ErrRepoExist is returned when a repository already exists.
ErrRepoExist = errors.New("repository already exists")
// ErrUserNotFound is returned when a user does not exist.
ErrUserNotFound = errors.New("user does not exist")
// ErrUserNotFound is returned when a user is not found.
ErrUserNotFound = errors.New("user not found")
// ErrTokenNotFound is returned when a token is not found.
ErrTokenNotFound = errors.New("token not found")
// ErrTokenExpired is returned when a token is expired.
ErrTokenExpired = errors.New("token expired")
)
2 changes: 2 additions & 0 deletions server/proto/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type User interface {
IsAdmin() bool
// PublicKeys returns the user's public keys.
PublicKeys() []ssh.PublicKey
// Password returns the user's password hash.
Password() string
}

// UserOptions are options for creating a user.
Expand Down
1 change: 1 addition & 0 deletions server/ssh/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
pubkeyCommand(),
setUsernameCommand(),
jwtCommand(),
tokenCommand(),
)
}

Expand Down
Loading

0 comments on commit a9e5ace

Please sign in to comment.