mirror of
https://gitea.com/gitea/tea.git
synced 2026-06-06 03:08:44 +02:00
6dd33b5f4f
Closes #1013. ## Background While trying to use `tea` from a non-interactive context I hit a friction point: after `tea login add` succeeded, plain `git push` over HTTPS still prompted for credentials. I filed #1013 as a feature request to add credential helper integration — then found, on reading the source, that the integration **already exists**: - `tea login add` accepts `--helper` (alias `-j`), which calls `task.SetupHelper` to register a credential helper in `~/.gitconfig`. - `tea login helper get` correctly implements git's credential protocol, reading the request from stdin and returning `protocol`/`host`/`username`/`password` lines. - `tea login helper setup` does the same for every configured login. I verified end-to-end that this works as advertised: after `tea login helper setup`, an HTTPS `git push` against a configured Gitea host authenticates silently using the stored token, no prompts, with `GIT_TERMINAL_PROMPT=0` set as a safety check. So the feature is fine. The problem is that nobody can find it: | Surface | Before | Issue | |---|---|---| | Flag name on `tea login add` | `--helper` (alias `-j`) | Generic; nothing tying it to git or credentials | | Flag usage text | `"Add helper"` | Says nothing | | `tea login helper` command | `Hidden: true` | Not in `tea login --help` | | `tea login helper` usage | `"Git helper"` | Says nothing | | `tea login helper` description | `"Git helper"` | Same string again | | `store/erase` subcommand description | `"Command drops"` | Sentence fragment, no meaning | | `setup` subcommand description | `"Setup helper to tea authenticate"` | Awkward, doesn't explain what it touches | | `get` subcommand description | `"Get token to auth"` | Doesn't mention git, stdin, or the credential protocol | | Mention in `tea login add --help` | None | Feature is invisible | ## What this patch does Purely cosmetic / documentation changes — **no behavior changes**: 1. Renames `--helper` to `--git-credentials`, keeping `--helper` and `-j` as aliases so existing scripts and muscle memory keep working. 2. Removes `Hidden: true` from `tea login helper` so it appears in `tea login --help`. 3. Rewrites every placeholder `Usage` and `Description` string in the helper command tree to describe what the thing actually does. 4. Expands the top-level `Description` of `tea login add` to mention the option and explain what it does. 5. Prints a one-line hint after a successful non-helper login: `Tip: pass --git-credentials (or run 'tea login helper setup') to authenticate 'git push' and 'git clone' over HTTPS with this token.` The credential helper protocol implementation, `SetupHelper`'s gitconfig writes, and the `get`/`store`/`setup` action functions are all unchanged. ## Help output after the patch ``` $ tea login --help COMMANDS: ... helper, git-credential Act as a git credential helper for stored Gitea logins ... $ tea login helper --help NAME: tea logins helper - Act as a git credential helper for stored Gitea logins DESCRIPTION: Speaks git's credential helper protocol so that HTTPS push and clone operations against your configured Gitea instances authenticate silently using the tokens tea already stores. Typical use is automatic: 'tea login add --git-credentials' (or 'tea login helper setup' for existing logins) registers '!tea login helper' as a credential helper in ~/.gitconfig. Git then invokes the 'get' subcommand when it needs credentials for a configured host. COMMANDS: store, erase No-op (git credential protocol store/erase) setup Register tea as a git credential helper for every configured login get Return the stored token for a URL (git credential protocol) ``` ## Open questions for the reviewer A few choices in here that are subjective — happy to change any of them: - **Flag name**: `--git-credentials` was the first clear name I tried. `--credential-helper` and `--git-helper` are also reasonable. Or keep `--helper` as canonical and just fix its usage text. - **Canonical subcommand name**: I kept `helper` as canonical with `git-credential` as alias, matching what was already there. Could flip this — `gh` uses `gh auth git-credential` as canonical with no `helper` form. - **Should `--git-credentials` default to true?** Most users probably want it on; the current opt-in design surprises them. But flipping the default is a behavior change so I left it alone here. - **Should the hint be silenced by an env var or a config flag?** I left it always-on for the people who need to see it; can gate it if it bothers automation users. - **Teardown on `tea login delete`** would parallel the setup behavior, but is genuinely a separate change. Not in this PR. --- This patch was authored interactively with an AI assistant, driven and reviewed by a human (Tyler / @dinsmoor) every step. *pull request created by Tyler's lovingly wrangled demon machine <3* --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-on: https://gitea.com/gitea/tea/pulls/1014 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Tyler <tyler@dinsmoor.us> Co-committed-by: Tyler <tyler@dinsmoor.us>
232 lines
6.3 KiB
Go
232 lines
6.3 KiB
Go
// Copyright 2020 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package task
|
|
|
|
import (
|
|
stdctx "context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
gitea "gitea.dev/sdk"
|
|
|
|
"gitea.dev/tea/modules/config"
|
|
"gitea.dev/tea/modules/utils"
|
|
)
|
|
|
|
// SetupHelper add tea helper to config global
|
|
func SetupHelper(login config.Login) (ok bool, err error) {
|
|
// Check that the URL is not blank
|
|
if login.URL == "" {
|
|
return false, fmt.Errorf("invalid Gitea URL")
|
|
}
|
|
|
|
// get all helper to URL in git config
|
|
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
|
|
var currentHelpers []byte
|
|
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
|
|
currentHelpers = []byte{}
|
|
}
|
|
|
|
// Check if ared added tea helper
|
|
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
|
|
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// Add tea helper
|
|
if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
|
|
return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
|
|
} else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil {
|
|
return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// CreateLogin create a login to be stored in config
|
|
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
|
|
// checks ...
|
|
// ... if we have a url
|
|
if len(giteaURL) == 0 {
|
|
return fmt.Errorf("Gitea server URL is required")
|
|
}
|
|
|
|
// ... if there already exist a login with same name
|
|
if login, err := config.GetLoginByName(name); err != nil {
|
|
return err
|
|
} else if login != nil {
|
|
return fmt.Errorf("login name '%s' has already been used", login.Name)
|
|
}
|
|
// ... if we already use this token
|
|
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) {
|
|
login, err := config.GetLoginByToken(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if login != nil {
|
|
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
|
|
}
|
|
}
|
|
|
|
serverURL, err := utils.ValidateAuthenticationMethod(
|
|
giteaURL,
|
|
token,
|
|
user,
|
|
passwd,
|
|
sshAgent,
|
|
sshKey,
|
|
sshCertPrincipal,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// check if it's a certificate the principal doesn't matter as the user
|
|
// has explicitly selected this private key
|
|
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
|
|
sshCertPrincipal = "yes"
|
|
}
|
|
|
|
login := config.Login{
|
|
Name: name,
|
|
URL: serverURL.String(),
|
|
Token: token,
|
|
Insecure: insecure,
|
|
SSHKey: sshKey,
|
|
SSHCertPrincipal: sshCertPrincipal,
|
|
SSHKeyFingerprint: sshKeyFingerprint,
|
|
SSHAgent: sshAgent,
|
|
Created: time.Now().Unix(),
|
|
VersionCheck: versionCheck,
|
|
}
|
|
|
|
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
|
|
if login.Token, err = generateToken(ctx, login, user, passwd, otp, scopes); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
client := login.Client()
|
|
|
|
// Verify if authentication works and get user info
|
|
u, _, err := client.Users.GetMyUserInfo(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
login.User = u.UserName
|
|
|
|
if len(login.Name) == 0 {
|
|
if login.Name, err = GenerateLoginName(giteaURL, login.User); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// we do not have a method to get SSH config from api,
|
|
// so we just use the host
|
|
login.SSHHost = serverURL.Host
|
|
|
|
if len(sshKey) == 0 {
|
|
login.SSHKey, err = findSSHKey(ctx, client)
|
|
if err != nil {
|
|
fmt.Printf("Warning: problem while finding a SSH key: %s\n", err)
|
|
}
|
|
}
|
|
|
|
if err = config.AddLogin(&login); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
|
|
if addHelper {
|
|
if _, err := SetupHelper(login); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
fmt.Println("Tip: pass --git-credentials (or run 'tea login helper setup') to authenticate 'git push' and 'git clone' over HTTPS with this token.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func shouldCheckTokenUniqueness(token string, sshAgent bool, sshKey, sshCertPrincipal, sshKeyFingerprint string) bool {
|
|
if sshAgent || sshKey != "" || sshCertPrincipal != "" || sshKeyFingerprint != "" {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// generateToken creates a new token when given BasicAuth credentials
|
|
func generateToken(ctx stdctx.Context, login config.Login, user, pass, otp, scopes string) (string, error) {
|
|
opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)}
|
|
if otp != "" {
|
|
opts = append(opts, gitea.SetOTP(otp))
|
|
}
|
|
client := login.Client(opts...)
|
|
|
|
var tl []*gitea.AccessToken
|
|
for page := 1; ; {
|
|
page_tokens, resp, err := client.Users.ListAccessTokens(ctx, gitea.ListAccessTokensOptions{
|
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tl = append(tl, page_tokens...)
|
|
if resp == nil || resp.NextPage == 0 {
|
|
break
|
|
}
|
|
page = resp.NextPage
|
|
}
|
|
host, _ := os.Hostname()
|
|
tokenName := host + "-tea"
|
|
|
|
// append timestamp, if a token with this hostname already exists
|
|
for i := range tl {
|
|
if tl[i].Name == tokenName {
|
|
tokenName += time.Now().Format("2006-01-02_15-04-05")
|
|
break
|
|
}
|
|
}
|
|
|
|
var tokenScopes []gitea.AccessTokenScope
|
|
if len(scopes) == 0 {
|
|
tokenScopes = []gitea.AccessTokenScope{gitea.AccessTokenScopeAll}
|
|
} else {
|
|
for _, scope := range strings.Split(scopes, ",") {
|
|
tokenScopes = append(tokenScopes, gitea.AccessTokenScope(strings.TrimSpace(scope)))
|
|
}
|
|
}
|
|
|
|
t, _, err := client.Users.CreateAccessToken(ctx, gitea.CreateAccessTokenOption{
|
|
Name: tokenName,
|
|
Scopes: tokenScopes,
|
|
})
|
|
return t.Token, err
|
|
}
|
|
|
|
// GenerateLoginName generates a name string based on instance URL & adds username if the result is not unique
|
|
func GenerateLoginName(url, user string) (string, error) {
|
|
parsedURL, err := utils.NormalizeURL(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := parsedURL.Host
|
|
|
|
// append user name if login name already exists
|
|
if len(user) != 0 {
|
|
if login, err := config.GetLoginByName(name); err != nil {
|
|
return "", err
|
|
} else if login != nil {
|
|
return name + "_" + user, nil
|
|
}
|
|
}
|
|
|
|
return name, nil
|
|
}
|