mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Push to gists with access tokens (#752)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
This commit is contained in:
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/thomiceli/opengist/internal/auth/ldap"
|
"github.com/thomiceli/opengist/internal/auth/ldap"
|
||||||
@@ -37,6 +38,37 @@ func TryAuthentication(username, password string) (*db.User, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TryAuthenticationWithAccessToken attempts to authenticate using a plain-text access token.
|
||||||
|
// It verifies the token belongs to the given username, is not expired, and has the required scope/permission.
|
||||||
|
func TryAuthenticationWithAccessToken(username, plainToken string, scope, permission uint) (*db.User, *db.AccessToken, error) {
|
||||||
|
if !strings.HasPrefix(plainToken, "og_") {
|
||||||
|
return nil, nil, AuthError{"not an access token"}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := db.GetAccessTokenByToken(plainToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, AuthError{"invalid access token"}
|
||||||
|
}
|
||||||
|
log.Error().Err(err).Msg("Cannot get access token")
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.IsExpired() {
|
||||||
|
return nil, nil, AuthError{"access token is expired"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.User.Username != username {
|
||||||
|
return nil, nil, AuthError{"access token does not belong to user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.CheckForPermission(scope, permission) {
|
||||||
|
return nil, nil, AuthError{"access token does not have required permission"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &token.User, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
func tryDbLogin(user *db.User, password string) (*db.User, error) {
|
func tryDbLogin(user *db.User, password string) (*db.User, error) {
|
||||||
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func handlePull(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists
|
|||||||
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
|
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
|
||||||
}
|
}
|
||||||
|
|
||||||
if user, err := authOrFail(ctx, userToCheck, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
if user, err := authOrFail(ctx, userToCheck, password, db.ScopeGist, db.ReadPermission, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists
|
|||||||
|
|
||||||
if gistExists {
|
if gistExists {
|
||||||
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
|
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
|
||||||
if user, err := authOrFail(ctx, gist.User.Username, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
if user, err := authOrFail(ctx, gist.User.Username, password, db.ScopeGist, db.ReadWritePermission, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists
|
|||||||
|
|
||||||
// The gist does not exist: the user creates it by pushing to /<user>/<name>.
|
// The gist does not exist: the user creates it by pushing to /<user>/<name>.
|
||||||
log.Debug().Str("authUsername", username).Msg("Creating new gist by pushing")
|
log.Debug().Str("authUsername", username).Msg("Creating new gist by pushing")
|
||||||
user, err := authOrFail(ctx, username, password, 404, "Check your credentials or make sure you have access to the Gist")
|
user, err := authOrFail(ctx, username, password, db.ScopeGist, db.ReadWritePermission, 404, "Check your credentials or make sure you have access to the Gist")
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"github.com/thomiceli/opengist/internal/web/context"
|
"github.com/thomiceli/opengist/internal/web/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authOrFail authenticates the given credentials. On success it returns the
|
// authOrFail authenticates the given credentials. The credential is tried as a
|
||||||
// user. On failure it writes the appropriate HTTP response and returns a nil
|
// password (DB or LDAP) first, then as an access token with the required scope
|
||||||
// user, so callers stop with:
|
// and permission — this lets SSO/passwordless users authenticate over Git HTTP.
|
||||||
|
// On success it returns the user. On failure it writes the appropriate HTTP
|
||||||
|
// response and returns a nil user, so callers stop with:
|
||||||
//
|
//
|
||||||
// user, err := authOrFail(...)
|
// user, err := authOrFail(...)
|
||||||
// if user == nil {
|
// if user == nil {
|
||||||
@@ -22,18 +24,26 @@ import (
|
|||||||
//
|
//
|
||||||
// `err` is nil for an already-written invalid-credentials response and a
|
// `err` is nil for an already-written invalid-credentials response and a
|
||||||
// renderable error for an internal authentication failure.
|
// renderable error for an internal authentication failure.
|
||||||
func authOrFail(ctx *context.Context, username, password string, invalidCode int, invalidMsg string) (*db.User, error) {
|
func authOrFail(ctx *context.Context, username, password string, scope, permission uint, invalidCode int, invalidMsg string) (*db.User, error) {
|
||||||
user, err := auth.TryAuthentication(username, password)
|
user, err := auth.TryAuthentication(username, password)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var authErr auth.AuthError
|
var authErr auth.AuthError
|
||||||
if errors.As(err, &authErr) {
|
if !errors.As(err, &authErr) {
|
||||||
|
return nil, ctx.ErrorRes(500, "Authentication system error", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to access token authentication.
|
||||||
|
user, token, tokenErr := auth.TryAuthenticationWithAccessToken(username, password, scope, permission)
|
||||||
|
if tokenErr != nil {
|
||||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||||
return nil, ctx.PlainText(invalidCode, invalidMsg)
|
return nil, ctx.PlainText(invalidCode, invalidMsg)
|
||||||
}
|
}
|
||||||
return nil, ctx.ErrorRes(500, "Authentication system error", nil)
|
_ = token.UpdateLastUsed()
|
||||||
|
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func basicAuth(ctx *context.Context) error {
|
func basicAuth(ctx *context.Context) error {
|
||||||
|
|||||||
@@ -312,6 +312,61 @@ func TestGitInitPushParallel(t *testing.T) {
|
|||||||
require.EqualValues(t, 0, queued, "init queue should be empty after all pushes complete")
|
require.EqualValues(t, 0, queued, "init queue should be empty after all pushes complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitAuthWithAccessToken(t *testing.T) {
|
||||||
|
s := webtest.Setup(t)
|
||||||
|
defer webtest.Teardown(t)
|
||||||
|
|
||||||
|
baseUrl := s.StartHttpServer(t)
|
||||||
|
|
||||||
|
s.Register(t, "thomas")
|
||||||
|
|
||||||
|
_, _, user, privateId := s.CreateGist(t, "2")
|
||||||
|
|
||||||
|
// CreateGist logs out at the end; log back in to create tokens for thomas.
|
||||||
|
s.Login(t, "thomas")
|
||||||
|
|
||||||
|
rwToken := s.CreateAccessToken(t, "rw", db.ReadWritePermission, db.NoPermission)
|
||||||
|
roToken := s.CreateAccessToken(t, "ro", db.ReadPermission, db.NoPermission)
|
||||||
|
noToken := s.CreateAccessToken(t, "none", db.NoPermission, db.NoPermission)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
// clone (pull) requires gist read permission, push requires gist write permission
|
||||||
|
canClone bool
|
||||||
|
canPush bool
|
||||||
|
}{
|
||||||
|
{"ReadWriteToken", rwToken, true, true},
|
||||||
|
{"ReadOnlyToken", roToken, true, false},
|
||||||
|
{"NoGistPermissionToken", noToken, false, false},
|
||||||
|
{"InvalidToken", "og_deadbeef", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
creds := "thomas:" + tt.token
|
||||||
|
|
||||||
|
// Clone (pull) the private gist using the token as the password.
|
||||||
|
dest := t.TempDir()
|
||||||
|
err := gitClone(baseUrl, creds, user, privateId, dest)
|
||||||
|
if tt.canClone {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to the gist using the token as the password.
|
||||||
|
err = gitPush(dest, "token.txt", "from token")
|
||||||
|
if tt.canPush {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitCreatePush(t *testing.T) {
|
func TestGitCreatePush(t *testing.T) {
|
||||||
s := webtest.Setup(t)
|
s := webtest.Setup(t)
|
||||||
defer webtest.Teardown(t)
|
defer webtest.Teardown(t)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func classifyInitRequest(urlPath string) (initKind, string) {
|
|||||||
// handleInit serves a request belonging to the "git push .../init" flow, after
|
// handleInit serves a request belonging to the "git push .../init" flow, after
|
||||||
// the caller has been authenticated.
|
// the caller has been authenticated.
|
||||||
func handleInit(ctx *context.Context, route *gitRoute, kind initKind, token, username, password string) error {
|
func handleInit(ctx *context.Context, route *gitRoute, kind initKind, token, username, password string) error {
|
||||||
user, err := authOrFail(ctx, username, password, 401, "Invalid credentials")
|
user, err := authOrFail(ctx, username, password, db.ScopeGist, db.ReadWritePermission, 401, "Invalid credentials")
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user