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

This commit is contained in:
Thomas
2026-06-30 00:00:37 +07:00
committed by GitHub
parent 0f942c8af8
commit fc10bb6688
5 changed files with 107 additions and 10 deletions
+32
View File
@@ -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 {
+3 -3
View File
@@ -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
} }
+16 -6
View File
@@ -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 {
+55
View File
@@ -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)
+1 -1
View File
@@ -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
} }