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 (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"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) {
|
||||
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists
|
||||
|
||||
if gistExists {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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>.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// authOrFail authenticates the given credentials. On success it returns the
|
||||
// user. On failure it writes the appropriate HTTP response and returns a nil
|
||||
// user, so callers stop with:
|
||||
// authOrFail authenticates the given credentials. The credential is tried as a
|
||||
// password (DB or LDAP) first, then as an access token with the required scope
|
||||
// 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(...)
|
||||
// if user == nil {
|
||||
@@ -22,18 +24,26 @@ import (
|
||||
//
|
||||
// `err` is nil for an already-written invalid-credentials response and a
|
||||
// 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)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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())
|
||||
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 {
|
||||
|
||||
@@ -312,6 +312,61 @@ func TestGitInitPushParallel(t *testing.T) {
|
||||
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) {
|
||||
s := webtest.Setup(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
|
||||
// the caller has been authenticated.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user