Add support for authentication via ssh certificates and pub/privatekey (#442)
This adds support for authentication using a SSH certificate and normal public keys when you've got an ssh-agent running that has this certificate or your public key loaded. First question when creating a new login is to ask about the ssh certificates or public keys, when the answer is yes, we don't need to ask about tokens/usernames anymore. Co-authored-by: Wim <wim@42.be> Reviewed-on: https://gitea.com/gitea/tea/pulls/442 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-by: 6543 <6543@obermui.de> Co-authored-by: Wim <42wim@noreply.gitea.io> Co-committed-by: Wim <42wim@noreply.gitea.io>
This commit is contained in:
parent
4ee5ce4b52
commit
6a4ba6a689
|
@ -53,13 +53,23 @@ var CmdLoginAdd = cli.Command{
|
|||
&cli.StringFlag{
|
||||
Name: "ssh-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Path to a SSH key to use, overrides auto-discovery",
|
||||
Usage: "Path to a SSH key/certificate to use, overrides auto-discovery",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "insecure",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Disable TLS verification",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ssh-agent-principal",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "Use SSH certificate with specified principal to login (needs a running ssh-agent with certificate loaded)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ssh-agent-key",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Use SSH public key or SSH fingerprint to login (needs a running ssh-agent with ssh key loaded)",
|
||||
},
|
||||
},
|
||||
Action: runLoginAdd,
|
||||
}
|
||||
|
@ -70,6 +80,11 @@ func runLoginAdd(ctx *cli.Context) error {
|
|||
return interact.CreateLogin()
|
||||
}
|
||||
|
||||
sshAgent := false
|
||||
if ctx.String("ssh-agent-key") != "" || ctx.String("ssh-agent-principal") != "" {
|
||||
sshAgent = true
|
||||
}
|
||||
|
||||
// else use args to add login
|
||||
return task.CreateLogin(
|
||||
ctx.String("name"),
|
||||
|
@ -78,5 +93,8 @@ func runLoginAdd(ctx *cli.Context) error {
|
|||
ctx.String("password"),
|
||||
ctx.String("ssh-key"),
|
||||
ctx.String("url"),
|
||||
ctx.Bool("insecure"))
|
||||
ctx.String("ssh-agent-principal"),
|
||||
ctx.String("ssh-agent-key"),
|
||||
ctx.Bool("insecure"),
|
||||
sshAgent)
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
)
|
||||
|
||||
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
|
||||
|
@ -27,6 +29,10 @@ type Login struct {
|
|||
// optional path to the private key
|
||||
SSHKey string `yaml:"ssh_key"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
|
||||
SSHAgent bool `yaml:"ssh_agent"`
|
||||
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
|
||||
SSHPassphrase string `yaml:"-"`
|
||||
// User is username from gitea
|
||||
User string `yaml:"user"`
|
||||
// Created is auto created unix timestamp
|
||||
|
@ -132,7 +138,7 @@ func GetLoginByHost(host string) *Login {
|
|||
|
||||
// DeleteLogin delete a login by name from config
|
||||
func DeleteLogin(name string) error {
|
||||
var idx = -1
|
||||
idx := -1
|
||||
for i, l := range config.Logins {
|
||||
if l.Name == name {
|
||||
idx = i
|
||||
|
@ -172,11 +178,27 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
|||
Jar: cookieJar,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient))
|
||||
|
||||
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
|
||||
promptPW := &survey.Password{Message: "ssh-key is encrypted please enter the passphrase: "}
|
||||
if err = survey.AskOne(promptPW, &l.SSHPassphrase, survey.WithValidator(survey.Required)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if l.SSHCertPrincipal != "" {
|
||||
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
|
||||
}
|
||||
|
||||
if l.SSHKeyFingerprint != "" {
|
||||
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase))
|
||||
}
|
||||
|
||||
client, err := gitea.NewClient(l.URL, options...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
|
@ -6,6 +6,7 @@ package interact
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/modules/task"
|
||||
|
@ -15,8 +16,9 @@ import (
|
|||
|
||||
// CreateLogin create an login interactive
|
||||
func CreateLogin() error {
|
||||
var name, token, user, passwd, sshKey, giteaURL string
|
||||
var name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string
|
||||
var insecure = false
|
||||
var sshAgent = false
|
||||
|
||||
promptI := &survey.Input{Message: "URL of Gitea instance: "}
|
||||
if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil {
|
||||
|
@ -38,6 +40,13 @@ func CreateLogin() error {
|
|||
return err
|
||||
}
|
||||
|
||||
loginMethod, err := promptSelect("Login with: ", []string{"token", "ssh-key/certificate"}, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch loginMethod {
|
||||
case "token":
|
||||
var hasToken bool
|
||||
promptYN := &survey.Confirm{
|
||||
Message: "Do you have an access token?",
|
||||
|
@ -63,9 +72,43 @@ func CreateLogin() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
case "ssh-key/certificate":
|
||||
promptI = &survey.Input{Message: "SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):"}
|
||||
if err := survey.AskOne(promptI, &sshKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sshKey == "" {
|
||||
sshKey, err = promptSelect("Select ssh-key: ", task.ListSSHPubkey(), "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ssh certificate
|
||||
if strings.Contains(sshKey, "principals") {
|
||||
sshCertPrincipal = regexp.MustCompile(`.*?principals: (.*?)[,|\s]`).FindStringSubmatch(sshKey)[1]
|
||||
if strings.Contains(sshKey, "(ssh-agent)") {
|
||||
sshAgent = true
|
||||
sshKey = ""
|
||||
} else {
|
||||
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
|
||||
sshKey = strings.TrimSuffix(sshKey, "-cert.pub")
|
||||
}
|
||||
} else {
|
||||
sshKeyFingerprint = regexp.MustCompile(`(SHA256:.*?)\s`).FindStringSubmatch(sshKey)[1]
|
||||
if strings.Contains(sshKey, "(ssh-agent)") {
|
||||
sshAgent = true
|
||||
sshKey = ""
|
||||
} else {
|
||||
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
|
||||
sshKey = strings.TrimSuffix(sshKey, ".pub")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var optSettings bool
|
||||
promptYN = &survey.Confirm{
|
||||
promptYN := &survey.Confirm{
|
||||
Message: "Set Optional settings: ",
|
||||
Default: false,
|
||||
}
|
||||
|
@ -87,5 +130,5 @@ func CreateLogin() error {
|
|||
}
|
||||
}
|
||||
|
||||
return task.CreateLogin(name, token, user, passwd, sshKey, giteaURL, insecure)
|
||||
return task.CreateLogin(name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import (
|
|||
)
|
||||
|
||||
// CreateLogin create a login to be stored in config
|
||||
func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bool) error {
|
||||
func CreateLogin(name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent bool) error {
|
||||
// checks ...
|
||||
// ... if we have a url
|
||||
if len(giteaURL) == 0 {
|
||||
|
@ -32,6 +32,7 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo
|
|||
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
|
||||
}
|
||||
|
||||
if !sshAgent && sshCertPrincipal == "" && sshKey == "" {
|
||||
// .. if we have enough information to authenticate
|
||||
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
|
||||
return fmt.Errorf("No token set")
|
||||
|
@ -40,6 +41,7 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo
|
|||
} else if len(user) == 0 && len(passwd) != 0 {
|
||||
return fmt.Errorf("No user set")
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
serverURL, err := utils.NormalizeURL(giteaURL)
|
||||
|
@ -47,16 +49,25 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo
|
|||
return fmt.Errorf("Unable to parse URL: %s", 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(),
|
||||
}
|
||||
|
||||
if len(token) == 0 {
|
||||
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
|
||||
if login.Token, err = generateToken(login, user, passwd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ListSSHPubkey lists all the ssh keys in the ssh agent and the ~/.ssh/*.pub files
|
||||
// It returns a list of SSH keys in the format of:
|
||||
// "fingerprint keytype comment - principals: principals (ssh-agent or path to pubkey file)"
|
||||
func ListSSHPubkey() []string {
|
||||
var keys []string
|
||||
|
||||
keys = append(keys, getAgentKeys()...)
|
||||
keys = append(keys, getLocalKeys()...)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func getAgentKeys() []string {
|
||||
ag, err := gitea.GetAgent()
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
akeys, err := ag.List()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
|
||||
for _, akey := range akeys {
|
||||
if key := parseKeys([]byte(akey.String()), "ssh-agent"); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func getLocalKeys() []string {
|
||||
var keys []string
|
||||
|
||||
// enumerate ~/.ssh/*.pub files
|
||||
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
localPubkeyPaths, err := filepath.Glob(glob)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// parse each local key with present privkey & compare fingerprints to online keys
|
||||
for _, pubkeyPath := range localPubkeyPaths {
|
||||
var pubkeyFile []byte
|
||||
pubkeyFile, err = ioutil.ReadFile(pubkeyPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if key := parseKeys(pubkeyFile, pubkeyPath); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func parseKeys(pkinput []byte, sshPath string) string {
|
||||
pkey, comment, _, _, err := ssh.ParseAuthorizedKey(pkinput)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(pkey.Type(), "cert-v01@openssh.com") {
|
||||
principals := pkey.(*ssh.Certificate).ValidPrincipals
|
||||
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment +
|
||||
" - principals: " + strings.Join(principals, ",") + " (" + sshPath + ")"
|
||||
}
|
||||
|
||||
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + " (" + sshPath + ")"
|
||||
}
|
||||
|
||||
func getCertPrincipals(pkey ssh.PublicKey) []string {
|
||||
var principals []string
|
||||
|
||||
if cert, ok := pkey.(*ssh.Certificate); ok {
|
||||
for _, principal := range cert.ValidPrincipals {
|
||||
principals = append(principals, principal)
|
||||
}
|
||||
}
|
||||
|
||||
return principals
|
||||
}
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Contains checks containment
|
||||
func Contains(haystack []string, needle string) bool {
|
||||
return IndexOf(haystack, needle) != -1
|
||||
|
@ -18,3 +24,20 @@ func IndexOf(haystack []string, needle string) int {
|
|||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// IsKeyEncrypted checks if the key is encrypted
|
||||
func IsKeyEncrypted(sshKey string) (bool, error) {
|
||||
priv, err := os.ReadFile(sshKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = ssh.ParsePrivateKey(priv)
|
||||
if err != nil {
|
||||
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue