From 905276f24b915eb998aa6d7a759f34b68ebda4d6 Mon Sep 17 00:00:00 2001 From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com> Date: Thu, 28 Aug 2025 02:44:09 +0200 Subject: [PATCH] Init gist with regular urls via git CLI (http) (#501) --- Makefile | 2 +- config.yml | 2 +- docs/usage/init-via-git.md | 40 ++- internal/auth/{ => password}/argon2id.go | 5 +- internal/auth/password/password.go | 6 +- internal/auth/{ => totp}/aes.go | 2 +- internal/auth/try_login.go | 83 +++++ internal/db/db.go | 2 +- internal/db/totp.go | 8 +- internal/web/context/context.go | 9 +- internal/web/handlers/auth/password.go | 83 +---- internal/web/handlers/git/http.go | 376 +++++++++++++---------- internal/web/test/auth_test.go | 202 +++++++++--- internal/web/test/server.go | 9 + 14 files changed, 522 insertions(+), 307 deletions(-) rename internal/auth/{ => password}/argon2id.go (98%) rename internal/auth/{ => totp}/aes.go (98%) create mode 100644 internal/auth/try_login.go diff --git a/Makefile b/Makefile index f8a3425..aad1e94 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ watch_backend: OG_DEV=1 npx nodemon --watch '**/*' -e html,yml,go,js --signal SIGTERM --exec 'go run -ldflags "-X $(VERSION_PKG)=$(GIT_TAG)" . --config config.yml' watch: - @sh ./scripts/watch.sh + @bash ./scripts/watch.sh clean: @echo "Cleaning up build artifacts..." diff --git a/config.yml b/config.yml index 48bf1a5..45245ed 100644 --- a/config.yml +++ b/config.yml @@ -3,7 +3,7 @@ # https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md # Set the log level to one of the following: debug, info, warn, error, fatal. Default: warn -log-level: warn +log-level: debug # Set the log output to one or more of the following: `stdout`, `file`. Default: stdout,file log-output: stdout,file diff --git a/docs/usage/init-via-git.md b/docs/usage/init-via-git.md index 3725896..ab661a3 100644 --- a/docs/usage/init-via-git.md +++ b/docs/usage/init-via-git.md @@ -1,6 +1,6 @@ # Init Gists via Git -Opengist allows you to create new snippets via Git over HTTP. +Opengist allows you to create new snippets via Git over HTTP. You can create gists with either auto-generated URLs or custom URLs of your choice. Simply init a new Git repository where your file(s) is/are located: @@ -10,19 +10,41 @@ git add . git commit -m "My cool snippet" ``` -Then add this Opengist special remote URL and push your changes: +### Option A: Regular URL + +Create a gist with a custom URL using the format `http://opengist.url/username/custom-url`, where `username` is your authenticated username and `custom-url` is your desired gist identifier. + +The gist must not exist yet if you want to create it, otherwise you will just push to the existing gist. ```shell -git remote add origin http://localhost:6157/init +git remote add origin http://opengist.url/thomas/my-custom-gist git push -u origin master ``` -Log in with your Opengist account credentials, and your snippet will be created at the specified URL: +**Requirements for custom URLs:** +- The username must match your authenticated username +- URL format: `http://opengist.url/username/custom-url` +- The custom URL becomes your gist's identifier and title +- `.git` suffix is automatically removed if present + +### Option B: Init endpoint + +Use the special `http://opengist.url/init` endpoint to create a gist with an automatically generated URL: ```shell -Username for 'http://localhost:6157': thomas -Password for 'http://thomas@localhost:6157': +git remote add origin http://opengist.url/init + +git push -u origin master +``` + +## Authentication + +When you push, you'll be prompted to authenticate: + +```shell +Username for 'http://opengist.url': thomas +Password for 'http://thomas@opengist.url': [your-password] Enumerating objects: 3, done. Counting objects: 100% (3/3), done. Delta compression using up to 8 threads @@ -30,12 +52,12 @@ Compressing objects: 100% (2/2), done. Writing objects: 100% (3/3), 416 bytes | 416.00 KiB/s, done. Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 remote: -remote: Your new repository has been created here: http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066 +remote: Your new repository has been created here: http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066 remote: remote: If you want to keep working with your gist, you could set the remote URL via: -remote: git remote set-url origin http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066 +remote: git remote set-url origin http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066 remote: -To http://localhost:6157/init +To http://opengist.url/init * [new branch] master -> master ``` diff --git a/internal/auth/argon2id.go b/internal/auth/password/argon2id.go similarity index 98% rename from internal/auth/argon2id.go rename to internal/auth/password/argon2id.go index 765fceb..4d95808 100644 --- a/internal/auth/argon2id.go +++ b/internal/auth/password/argon2id.go @@ -1,4 +1,4 @@ -package auth +package password import ( "crypto/rand" @@ -6,8 +6,9 @@ import ( "encoding/base64" "errors" "fmt" - "golang.org/x/crypto/argon2" "strings" + + "golang.org/x/crypto/argon2" ) type argon2ID struct { diff --git a/internal/auth/password/password.go b/internal/auth/password/password.go index 9e96130..741531e 100644 --- a/internal/auth/password/password.go +++ b/internal/auth/password/password.go @@ -1,11 +1,9 @@ package password -import "github.com/thomiceli/opengist/internal/auth" - func HashPassword(code string) (string, error) { - return auth.Argon2id.Hash(code) + return Argon2id.Hash(code) } func VerifyPassword(code, hashedCode string) (bool, error) { - return auth.Argon2id.Verify(code, hashedCode) + return Argon2id.Verify(code, hashedCode) } diff --git a/internal/auth/aes.go b/internal/auth/totp/aes.go similarity index 98% rename from internal/auth/aes.go rename to internal/auth/totp/aes.go index e64c116..8a5b6b6 100644 --- a/internal/auth/aes.go +++ b/internal/auth/totp/aes.go @@ -1,4 +1,4 @@ -package auth +package totp import ( "crypto/aes" diff --git a/internal/auth/try_login.go b/internal/auth/try_login.go new file mode 100644 index 0000000..aa7ce1e --- /dev/null +++ b/internal/auth/try_login.go @@ -0,0 +1,83 @@ +package auth + +import ( + "errors" + + "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/auth/ldap" + passwordpkg "github.com/thomiceli/opengist/internal/auth/password" + "github.com/thomiceli/opengist/internal/db" + "gorm.io/gorm" +) + +type AuthError struct { + message string +} + +func (e AuthError) Error() string { + return e.message +} + +func TryAuthentication(username, password string) (*db.User, error) { + user, err := db.GetUserByUsername(username) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Error().Err(err).Msgf("Cannot get user by username %s", username) + return nil, err + } + } + + if user.Password != "" { + return tryDbLogin(user, password) + } else { + if ldap.Enabled() { + return tryLdapLogin(username, password) + } + return nil, AuthError{"no authentication method available"} + } +} + +func tryDbLogin(user *db.User, password string) (*db.User, error) { + if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok { + if err != nil { + log.Error().Err(err).Msg("Password verification failed") + return nil, err + } + return nil, AuthError{"invalid password"} + } + + return user, nil +} + +func tryLdapLogin(username, password string) (user *db.User, err error) { + ok, err := ldap.Authenticate(username, password) + if err != nil { + log.Error().Err(err).Msg("LDAP authentication failed") + return nil, err + } + + if !ok { + return nil, AuthError{"invalid LDAP credentials"} + } + + if user, err = db.GetUserByUsername(username); err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Error().Err(err).Msgf("Cannot get user by username %s", username) + return nil, err + } + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + user = &db.User{ + Username: username, + } + if err = user.Create(); err != nil { + log.Warn().Err(err).Msg("Cannot create user after LDAP authentication") + return nil, err + } + + return user, nil + } + + return user, nil +} diff --git a/internal/db/db.go b/internal/db/db.go index 1d3a19b..b8bb778 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -269,5 +269,5 @@ func DeprecationDBFilename() { } func TruncateDatabase() error { - return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}) + return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}) } diff --git a/internal/db/totp.go b/internal/db/totp.go index cc8421d..f463c66 100644 --- a/internal/db/totp.go +++ b/internal/db/totp.go @@ -6,11 +6,11 @@ import ( "encoding/hex" "encoding/json" "fmt" - "github.com/thomiceli/opengist/internal/auth" + "slices" + "github.com/thomiceli/opengist/internal/auth/password" ogtotp "github.com/thomiceli/opengist/internal/auth/totp" "github.com/thomiceli/opengist/internal/config" - "slices" ) type TOTP struct { @@ -31,7 +31,7 @@ func GetTOTPByUserID(userID uint) (*TOTP, error) { func (totp *TOTP) StoreSecret(secret string) error { secretBytes := []byte(secret) - encrypted, err := auth.AESEncrypt(config.SecretKey, secretBytes) + encrypted, err := ogtotp.AESEncrypt(config.SecretKey, secretBytes) if err != nil { return err } @@ -46,7 +46,7 @@ func (totp *TOTP) ValidateCode(code string) (bool, error) { return false, err } - secretBytes, err := auth.AESDecrypt(config.SecretKey, ciphertext) + secretBytes, err := ogtotp.AESDecrypt(config.SecretKey, ciphertext) if err != nil { return false, err } diff --git a/internal/web/context/context.go b/internal/web/context/context.go index 9ea3c80..6cf750a 100644 --- a/internal/web/context/context.go +++ b/internal/web/context/context.go @@ -2,15 +2,16 @@ package context import ( "context" + "html/template" + "net/http" + "sync" + "github.com/gorilla/sessions" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" - "html/template" - "net/http" - "sync" ) type dataKey string @@ -57,7 +58,7 @@ func (ctx *Context) DataMap() echo.Map { } func (ctx *Context) ErrorRes(code int, message string, err error) error { - if code >= 500 { + if code >= 500 && err != nil { var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger() skipLogger.Error().Err(err).Msg(message) } diff --git a/internal/web/handlers/auth/password.go b/internal/web/handlers/auth/password.go index 24e57a6..6d1b9d2 100644 --- a/internal/web/handlers/auth/password.go +++ b/internal/web/handlers/auth/password.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/rs/zerolog/log" - "github.com/thomiceli/opengist/internal/auth/ldap" + "github.com/thomiceli/opengist/internal/auth" passwordpkg "github.com/thomiceli/opengist/internal/auth/password" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" @@ -125,24 +125,15 @@ func ProcessLogin(ctx *context.Context) error { return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err) } - localUser, err := db.GetUserByUsername(dto.Username) - hasLocalPassword := err == nil && localUser.Password != "" - - if hasLocalPassword { - if user, err = tryDbLogin(ctx, dto.Username, dto.Password); user == nil { - return err - } - } else { - if ldap.Enabled() { - if user, err = tryLdapLogin(ctx, dto.Username, dto.Password); err != nil { - return err - } - } - if user == nil { - if user, err = tryDbLogin(ctx, dto.Username, dto.Password); user == nil { - return err - } + user, err = auth.TryAuthentication(dto.Username, dto.Password) + if err != nil { + var authErr auth.AuthError + if errors.As(err, &authErr) { + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") + return ctx.RedirectTo("/login") } + return ctx.ErrorRes(500, "Authentication system error", nil) } // handle MFA @@ -170,59 +161,3 @@ func Logout(ctx *context.Context) error { ctx.DeleteCsrfCookie() return ctx.RedirectTo("/all") } - -func tryDbLogin(ctx *context.Context, username, password string) (user *db.User, err error) { - if user, err = db.GetUserByUsername(username); err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ctx.ErrorRes(500, "Cannot get user", err) - } - - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") - return nil, ctx.RedirectTo("/login") - } - - if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok { - if err != nil { - return nil, ctx.ErrorRes(500, "Cannot check for password", err) - } - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") - return nil, ctx.RedirectTo("/login") - } - - return user, nil -} - -func tryLdapLogin(ctx *context.Context, username, password string) (user *db.User, err error) { - ok, err := ldap.Authenticate(username, password) - if err != nil { - log.Info().Err(err).Msgf("LDAP authentication error") - return nil, ctx.ErrorRes(500, "Cannot get user", err) - } - - if !ok { - log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP()) - return nil, nil - } - - if user, err = db.GetUserByUsername(username); err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ctx.ErrorRes(500, "Cannot get user", err) - } - } - - if errors.Is(err, gorm.ErrRecordNotFound) { - user = &db.User{ - Username: username, - } - if err = user.Create(); err != nil { - log.Warn().Err(err).Msg("Cannot create user after LDAP authentication") - return nil, ctx.ErrorRes(500, "Cannot create user", err) - } - - return user, nil - } - - return user, nil -} diff --git a/internal/web/handlers/git/http.go b/internal/web/handlers/git/http.go index ecde51d..3b4054a 100644 --- a/internal/web/handlers/git/http.go +++ b/internal/web/handlers/git/http.go @@ -15,17 +15,13 @@ import ( "strings" "time" - "github.com/thomiceli/opengist/internal/auth/ldap" - "github.com/thomiceli/opengist/internal/auth/password" - "github.com/thomiceli/opengist/internal/web/context" - "github.com/thomiceli/opengist/internal/web/handlers" - "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/auth" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "gorm.io/gorm" + "github.com/thomiceli/opengist/internal/web/context" + "github.com/thomiceli/opengist/internal/web/handlers" ) var routes = []struct { @@ -47,165 +43,211 @@ var routes = []struct { } func GitHttp(ctx *context.Context) error { + route := findMatchingRoute(ctx) + if route == nil { + return ctx.NotFound("Gist not found") // regular 404 for non-git routes + } + gist := ctx.GetData("gist").(*db.Gist) + gistExists := gist.ID != 0 + + isInitRoute := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs") + isInitRouteReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack") + isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$") + isPull := ctx.QueryParam("service") == "git-upload-pack" || + strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") && !isInfoRefs + isPush := ctx.QueryParam("service") == "git-receive-pack" || + strings.HasSuffix(ctx.Request().URL.Path, "git-receive-pack") && !isInfoRefs + + repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid) + ctx.SetData("repositoryPath", repositoryPath) + + allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true) + if err != nil { + log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed") + } + + // No need to authenticate if the user wants + // to clone/pull ; a non-private gist ; that exists ; where unauthenticated access is allowed in the instance + if isPull && gist.Private != db.PrivateVisibility && gistExists && allow { + return route.handler(ctx) + } + + // Else we need to authenticate the user, that include other cases: + // - user wants to push the gist + // - user wants to clone/pull a private gist + // - user wants to clone/pull a non-private gist but unauthenticated access is not allowed + // - gist is not found ; has no right to clone/pull (obfuscation) + // - admin setting to require login is set to true + authUsername, authPassword, err := parseAuthHeader(ctx) + if err != nil { + return basicAuth(ctx) + } + + // if the user wants to create a gist via the /init route + if isInitRoute || isInitRouteReceive { + var user *db.User + + // check if the user has a valid account on opengist to push a gist + user, err = auth.TryAuthentication(authUsername, authPassword) + if err != nil { + var authErr auth.AuthError + if errors.As(err, &authErr) { + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return ctx.PlainText(401, "Invalid credentials") + } + return ctx.ErrorRes(500, "Authentication system error", nil) + } + + if isInitRoute { + gist, err = createGist(user, "") + if err != nil { + return ctx.ErrorRes(500, "Cannot create gist", err) + } + + err = db.AddInitGistToQueue(gist.ID, user.ID) + if err != nil { + return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err) + } + ctx.SetData("gist", gist) + return route.handler(ctx) + } else { + gist, err = db.GetInitGistInQueueForUser(user.ID) + if err != nil { + return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err) + } + + ctx.SetData("gist", gist) + ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid)) + return route.handler(ctx) + } + } + + // if clone/pull + // check if the gist exists and if the credentials are valid + if isPull { + log.Debug().Msg("Detected git pull operation") + if !gistExists { + log.Debug().Str("authUsername", authUsername).Msg("Pulling unknown gist") + return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") + } + + var userToCheckPermissions string + // if the user is trying to clone/pull a non-private gist while unauthenticated access is not allowed, + // check if the user has a valid account + if gist.Private != db.PrivateVisibility { + log.Debug().Str("authUsername", authUsername).Msg("Pulling non-private gist with authenticated access") + userToCheckPermissions = authUsername + } else { // else just check the password against the gist owner + log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pulling private gist") + userToCheckPermissions = gist.User.Username + } + + if _, err = auth.TryAuthentication(userToCheckPermissions, authPassword); err != nil { + var authErr auth.AuthError + if errors.As(err, &authErr) { + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") + } + return ctx.ErrorRes(500, "Authentication system error", nil) + } + log.Debug().Str("authUsername", authUsername).Msg("Pulling gist") + + return route.handler(ctx) + } + + if isPush { + log.Debug().Msg("Detected git push operation") + // if gist exists, check if the credentials are valid and if the user is the gist owner + if gistExists { + log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist") + if _, err = auth.TryAuthentication(gist.User.Username, authPassword); err != nil { + var authErr auth.AuthError + if errors.As(err, &authErr) { + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") + } + return ctx.ErrorRes(500, "Authentication system error", nil) + } + log.Debug().Str("authUsername", authUsername).Msg("Pushing gist") + + return route.handler(ctx) + } else { // if the gist does not exist, check if the user has a valid account on opengist to push a gist and create it + log.Debug().Str("authUsername", authUsername).Msg("Creating new gist by pushing") + var user *db.User + if user, err = auth.TryAuthentication(authUsername, authPassword); err != nil { + var authErr auth.AuthError + if errors.As(err, &authErr) { + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") + } + return ctx.ErrorRes(500, "Authentication system error", nil) + } + + urlPath := ctx.Request().URL.Path + pathParts := strings.Split(strings.Trim(urlPath, "/"), "/") + if pathParts[0] == authUsername && len(pathParts) == 4 { + log.Debug().Str("authUsername", authUsername).Msg("Valid URL format for push operation") + gist, err = createGist(user, pathParts[1]) + if err != nil { + return ctx.ErrorRes(500, "Cannot create gist", err) + } + log.Debug().Str("authUsername", authUsername).Str("url", urlPath).Msg("Gist created") + ctx.SetData("gist", gist) + ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid)) + } else { + log.Debug().Str("authUsername", authUsername).Any("path", pathParts).Msg("Invalid URL format for push operation") + return ctx.PlainText(401, "Invalid URL format for push operation") + } + return route.handler(ctx) + + } + } + + return route.handler(ctx) +} + +func findMatchingRoute(ctx *context.Context) *struct { + gitUrl string + method string + handler func(ctx *context.Context) error +} { for _, route := range routes { matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path) if ctx.Request().Method == route.method && matched { if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") { continue } - - gist := ctx.GetData("gist").(*db.Gist) - - isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs") - isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack") - isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$") - isPull := ctx.QueryParam("service") == "git-upload-pack" || - strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") || - ctx.Request().Method == "GET" && !isInfoRefs - - repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid) - if _, err := os.Stat(repositoryPath); os.IsNotExist(err) { - if err != nil { - log.Info().Err(err).Msg("Repository directory does not exist") - return ctx.ErrorRes(404, "Repository directory does not exist", err) - } - } - - ctx.SetData("repositoryPath", repositoryPath) - - allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true) - if err != nil { - log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed") - } - - // Shows basic auth if : - // - user wants to push the gist - // - user wants to clone/pull a private gist - // - gist is not found (obfuscation) - // - admin setting to require login is set to true - if isPull && gist.Private != db.PrivateVisibility && gist.ID != 0 && allow { - return route.handler(ctx) - } - - authHeader := ctx.Request().Header.Get("Authorization") - if authHeader == "" { - return basicAuth(ctx) - } - - authFields := strings.Fields(authHeader) - if len(authFields) != 2 || authFields[0] != "Basic" { - return basicAuth(ctx) - } - - authUsername, authPassword, err := basicAuthDecode(authFields[1]) - if err != nil { - return basicAuth(ctx) - } - - if !isInit && !isInitReceive { - if gist.ID == 0 { - return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") - } - - var userToCheckPermissions *db.User - if gist.Private != db.PrivateVisibility && isPull { - userToCheckPermissions, _ = db.GetUserByUsername(authUsername) - } else { - userToCheckPermissions = &gist.User - } - - // ldap - ldapSuccess := false - if ldap.Enabled() { - if ok, err := ldap.Authenticate(userToCheckPermissions.Username, authPassword); !ok { - if err != nil { - log.Warn().Err(err).Msg("LDAP authentication error") - } - log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP()) - } else { - ldapSuccess = true - } - } - - // password - if !ldapSuccess { - if ok, err := password.VerifyPassword(authPassword, userToCheckPermissions.Password); !ok { - if err != nil { - return ctx.ErrorRes(500, "Cannot verify password", err) - } - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist") - } - } - } else { - var user *db.User - if user, err = db.GetUserByUsername(authUsername); err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.ErrorRes(500, "Cannot get user", err) - } - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - return ctx.ErrorRes(401, "Invalid credentials", nil) - } - ldapSuccess := false - if ldap.Enabled() { - if ok, err := ldap.Authenticate(user.Username, authPassword); !ok { - if err != nil { - log.Warn().Err(err).Msg("LDAP authentication error") - } - log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP()) - } else { - ldapSuccess = true - } - } - if !ldapSuccess { - if ok, err := password.VerifyPassword(authPassword, user.Password); !ok { - if err != nil { - return ctx.ErrorRes(500, "Cannot check for password", err) - } - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - return ctx.ErrorRes(401, "Invalid credentials", nil) - } - } - - if isInit { - gist = new(db.Gist) - gist.UserID = user.ID - gist.User = *user - uuidGist, err := uuid.NewRandom() - if err != nil { - return ctx.ErrorRes(500, "Error creating an UUID", err) - } - gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1) - gist.Title = "gist:" + gist.Uuid - - if err = gist.InitRepository(); err != nil { - return ctx.ErrorRes(500, "Cannot init repository in the file system", err) - } - - if err = gist.Create(); err != nil { - return ctx.ErrorRes(500, "Cannot init repository in database", err) - } - - err = db.AddInitGistToQueue(gist.ID, user.ID) - if err != nil { - return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err) - } - ctx.SetData("gist", gist) - } else { - gist, err = db.GetInitGistInQueueForUser(user.ID) - if err != nil { - return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err) - } - - ctx.SetData("gist", gist) - ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid)) - } - } - - return route.handler(ctx) + return &route } } - return ctx.NotFound("Gist not found") + return nil +} + +func createGist(user *db.User, url string) (*db.Gist, error) { + gist := new(db.Gist) + gist.UserID = user.ID + gist.User = *user + uuidGist, err := uuid.NewRandom() + if err != nil { + return nil, err + } + gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1) + gist.Title = "gist:" + gist.Uuid + + if url != "" { + gist.URL = strings.TrimSuffix(url, ".git") + gist.Title = strings.TrimSuffix(url, ".git") + } + + if err := gist.InitRepository(); err != nil { + return nil, err + } + + if err := gist.Create(); err != nil { + return nil, err + } + + return gist, nil } func uploadPack(ctx *context.Context) error { @@ -331,6 +373,26 @@ func basicAuth(ctx *context.Context) error { return ctx.PlainText(401, "Requires authentication") } +func parseAuthHeader(ctx *context.Context) (string, string, error) { + authHeader := ctx.Request().Header.Get("Authorization") + if authHeader == "" { + return "", "", errors.New("no auth header") + } + + authFields := strings.Fields(authHeader) + if len(authFields) != 2 || authFields[0] != "Basic" { + return "", "", errors.New("invalid auth header") + } + + authUsername, authPassword, err := basicAuthDecode(authFields[1]) + if err != nil { + log.Error().Err(err).Msg("Cannot decode basic auth header") + return "", "", err + } + + return authUsername, authPassword, nil +} + func basicAuthDecode(encoded string) (string, string, error) { s, err := base64.StdEncoding.DecodeString(encoded) if err != nil { diff --git a/internal/web/test/auth_test.go b/internal/web/test/auth_test.go index b62fc83..41d250e 100644 --- a/internal/web/test/auth_test.go +++ b/internal/web/test/auth_test.go @@ -203,49 +203,28 @@ func TestGitOperations(t *testing.T) { err = s.Request("POST", "/", gist3, 302) require.NoError(t, err) - gitOperations := func(credentials, owner, url, filename string, expectErrorClone, expectErrorCheck, expectErrorPush bool) { - log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush) - err := clientGitClone(credentials, owner, url) - if expectErrorClone { - require.Error(t, err) - } else { - require.NoError(t, err) - } - err = clientCheckRepo(url, filename) - if expectErrorCheck { - require.Error(t, err) - } else { - require.NoError(t, err) - } - err = clientGitPush(url) - if expectErrorPush { - require.Error(t, err) - } else { - require.NoError(t, err) - } - } - tests := []struct { credentials string user string url string + pushOptions string expectErrorClone bool expectErrorCheck bool expectErrorPush bool }{ - {":", "kaguya", "kaguya-pub-gist", false, false, true}, - {":", "kaguya", "kaguya-unl-gist", false, false, true}, - {":", "kaguya", "kaguya-priv-gist", true, true, true}, - {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false}, - {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true}, + {":", "kaguya", "kaguya-pub-gist", "", false, false, true}, + {":", "kaguya", "kaguya-unl-gist", "", false, false, true}, + {":", "kaguya", "kaguya-priv-gist", "", true, true, true}, + {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false}, + {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false}, + {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false}, + {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true}, + {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true}, + {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true}, } for _, test := range tests { - gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) + gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) } login(t, s, admin) @@ -256,23 +235,24 @@ func TestGitOperations(t *testing.T) { credentials string user string url string + pushOptions string expectErrorClone bool expectErrorCheck bool expectErrorPush bool }{ - {":", "kaguya", "kaguya-pub-gist", true, true, true}, - {":", "kaguya", "kaguya-unl-gist", true, true, true}, - {":", "kaguya", "kaguya-priv-gist", true, true, true}, - {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false}, - {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true}, + {":", "kaguya", "kaguya-pub-gist", "", true, true, true}, + {":", "kaguya", "kaguya-unl-gist", "", true, true, true}, + {":", "kaguya", "kaguya-priv-gist", "", true, true, true}, + {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false}, + {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false}, + {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false}, + {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true}, + {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true}, + {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true}, } for _, test := range testsRequireLogin { - gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) + gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) } login(t, s, admin) @@ -280,31 +260,155 @@ func TestGitOperations(t *testing.T) { require.NoError(t, err) for _, test := range tests { - gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) + gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) } } +func TestGitInit(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + admin := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, admin) + s.sessionCookie = "" + register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"}) + s.sessionCookie = "" + register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"}) + + testsNewWithPush := []struct { + credentials string + user string + url string + pushOptions string + expectErrorClone bool + expectErrorCheck bool + expectErrorPush bool + }{ + {":", "kaguya", "gist1", "", true, true, true}, + {"kaguya:wrongpass", "kaguya", "gist2", "", true, true, true}, + {"fujiwara:fujiwara", "kaguya", "gist3", "", true, true, true}, + {"kaguya:kaguya", "kaguya", "gist4", "", false, false, false}, + {"kaguya:kaguya", "kaguya", "gist5/g", "", true, true, true}, + } + + for _, test := range testsNewWithPush { + gitInitPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorPush) + } + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, "kaguya", gist1db.User.Username) + + for _, test := range testsNewWithPush { + gitCloneCheckPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) + } + + count, err := db.CountAll(db.Gist{}) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + testsNewWithInit := []struct { + credentials string + url string + pushOptions string + expectErrorPush bool + }{ + {":", "init", "", true}, + {"fujiwara:wrongpass", "init", "", true}, + {"kaguya:kaguya", "init", "", false}, + {"fujiwara:fujiwara", "init", "", false}, + } + + for _, test := range testsNewWithInit { + gitInitPush(t, test.credentials, "kaguya", test.url, "newfile.txt", test.pushOptions, test.expectErrorPush) + } + + count, err = db.CountAll(db.Gist{}) + require.NoError(t, err) + require.Equal(t, int64(3), count) + + gist2db, err := db.GetGistByID("2") + require.NoError(t, err) + require.Equal(t, "kaguya", gist2db.User.Username) + + gist3db, err := db.GetGistByID("3") + require.NoError(t, err) + require.Equal(t, "fujiwara", gist3db.User.Username) +} + func clientGitClone(creds string, user string, url string) error { return exec.Command("git", "clone", "http://"+creds+"@localhost:6157/"+user+"/"+url, filepath.Join(config.GetHomeDir(), "tmp", url)).Run() } -func clientGitPush(url string) error { - f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, "newfile.txt")) +func clientGitPush(url string, pushOptions string, file string) error { + f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file)) if err != nil { return err } - f.Close() + _, _ = f.WriteString("new file") + _ = f.Close() - _ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", "newfile.txt").Run() + _ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run() _ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").Run() - err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin", "master").Run() - + if pushOptions != "" { + err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", pushOptions, "origin").Run() + } else { + err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin").Run() + } _ = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp", url)) return err } +func clientGitInit(path string) error { + return exec.Command("git", "init", "--initial-branch=master", filepath.Join(config.GetHomeDir(), "tmp", path)).Run() +} + +func clientGitSetRemote(path string, remoteName string, remoteUrl string) error { + return exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", path), "remote", "add", remoteName, remoteUrl).Run() +} + func clientCheckRepo(url string, file string) error { _, err := os.ReadFile(filepath.Join(config.GetHomeDir(), "tmp", url, file)) return err } + +func gitCloneCheckPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorClone, expectErrorCheck, expectErrorPush bool) { + log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush) + err := clientGitClone(credentials, owner, url) + if expectErrorClone { + require.Error(t, err) + } else { + require.NoError(t, err) + } + err = clientCheckRepo(url, filename) + if expectErrorCheck { + require.Error(t, err) + } else { + require.NoError(t, err) + } + err = clientGitPush(url, pushOptions, filename) + if expectErrorPush { + require.Error(t, err) + } else { + require.NoError(t, err) + } +} + +func gitInitPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorPush bool) { + log.Debug().Msgf("Testing %s %s %t", credentials, url, expectErrorPush) + err := clientGitInit(url) + require.NoError(t, err) + if url == "init" { + err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/init/") + } else { + err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/"+owner+"/"+url) + } + require.NoError(t, err) + err = clientGitPush(url, pushOptions, filename) + if expectErrorPush { + require.Error(t, err) + } else { + require.NoError(t, err) + } +} diff --git a/internal/web/test/server.go b/internal/web/test/server.go index 7625bf0..a6926bb 100644 --- a/internal/web/test/server.go +++ b/internal/web/test/server.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "os" + "os/exec" "path/filepath" "reflect" "runtime" @@ -153,8 +154,16 @@ func Setup(t *testing.T) *TestServer { config.C.Index = "" config.C.LogLevel = "error" + config.C.GitDefaultBranch = "master" config.InitLog() + err = exec.Command("git", "config", "--global", "--type", "bool", "push.autoSetupRemote", "true").Run() + require.NoError(t, err) + err = exec.Command("git", "config", "--global", "user.email", "test@opengist.io").Run() + require.NoError(t, err) + err = exec.Command("git", "config", "--global", "user.name", "test").Run() + require.NoError(t, err) + homePath := config.GetHomeDir() log.Info().Msg("Data directory: " + homePath)