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{
|
&cli.StringFlag{
|
||||||
Name: "ssh-key",
|
Name: "ssh-key",
|
||||||
Aliases: []string{"s"},
|
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{
|
&cli.BoolFlag{
|
||||||
Name: "insecure",
|
Name: "insecure",
|
||||||
Aliases: []string{"i"},
|
Aliases: []string{"i"},
|
||||||
Usage: "Disable TLS verification",
|
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,
|
Action: runLoginAdd,
|
||||||
}
|
}
|
||||||
|
@ -70,6 +80,11 @@ func runLoginAdd(ctx *cli.Context) error {
|
||||||
return interact.CreateLogin()
|
return interact.CreateLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sshAgent := false
|
||||||
|
if ctx.String("ssh-agent-key") != "" || ctx.String("ssh-agent-principal") != "" {
|
||||||
|
sshAgent = true
|
||||||
|
}
|
||||||
|
|
||||||
// else use args to add login
|
// else use args to add login
|
||||||
return task.CreateLogin(
|
return task.CreateLogin(
|
||||||
ctx.String("name"),
|
ctx.String("name"),
|
||||||
|
@ -78,5 +93,8 @@ func runLoginAdd(ctx *cli.Context) error {
|
||||||
ctx.String("password"),
|
ctx.String("password"),
|
||||||
ctx.String("ssh-key"),
|
ctx.String("ssh-key"),
|
||||||
ctx.String("url"),
|
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"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"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
|
// 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
|
// optional path to the private key
|
||||||
SSHKey string `yaml:"ssh_key"`
|
SSHKey string `yaml:"ssh_key"`
|
||||||
Insecure bool `yaml:"insecure"`
|
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 is username from gitea
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
// Created is auto created unix timestamp
|
// Created is auto created unix timestamp
|
||||||
|
@ -132,7 +138,7 @@ func GetLoginByHost(host string) *Login {
|
||||||
|
|
||||||
// DeleteLogin delete a login by name from config
|
// DeleteLogin delete a login by name from config
|
||||||
func DeleteLogin(name string) error {
|
func DeleteLogin(name string) error {
|
||||||
var idx = -1
|
idx := -1
|
||||||
for i, l := range config.Logins {
|
for i, l := range config.Logins {
|
||||||
if l.Name == name {
|
if l.Name == name {
|
||||||
idx = i
|
idx = i
|
||||||
|
@ -172,11 +178,27 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
||||||
Jar: cookieJar,
|
Jar: cookieJar,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}}
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient))
|
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...)
|
client, err := gitea.NewClient(l.URL, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
@ -6,6 +6,7 @@ package interact
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/tea/modules/task"
|
"code.gitea.io/tea/modules/task"
|
||||||
|
@ -15,8 +16,9 @@ import (
|
||||||
|
|
||||||
// CreateLogin create an login interactive
|
// CreateLogin create an login interactive
|
||||||
func CreateLogin() error {
|
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 insecure = false
|
||||||
|
var sshAgent = false
|
||||||
|
|
||||||
promptI := &survey.Input{Message: "URL of Gitea instance: "}
|
promptI := &survey.Input{Message: "URL of Gitea instance: "}
|
||||||
if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil {
|
if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil {
|
||||||
|
@ -38,6 +40,13 @@ func CreateLogin() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginMethod, err := promptSelect("Login with: ", []string{"token", "ssh-key/certificate"}, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch loginMethod {
|
||||||
|
case "token":
|
||||||
var hasToken bool
|
var hasToken bool
|
||||||
promptYN := &survey.Confirm{
|
promptYN := &survey.Confirm{
|
||||||
Message: "Do you have an access token?",
|
Message: "Do you have an access token?",
|
||||||
|
@ -63,9 +72,43 @@ func CreateLogin() error {
|
||||||
return err
|
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
|
var optSettings bool
|
||||||
promptYN = &survey.Confirm{
|
promptYN := &survey.Confirm{
|
||||||
Message: "Set Optional settings: ",
|
Message: "Set Optional settings: ",
|
||||||
Default: false,
|
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
|
// 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 ...
|
// checks ...
|
||||||
// ... if we have a url
|
// ... if we have a url
|
||||||
if len(giteaURL) == 0 {
|
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)
|
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 we have enough information to authenticate
|
||||||
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
|
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
|
||||||
return fmt.Errorf("No token set")
|
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 {
|
} else if len(user) == 0 && len(passwd) != 0 {
|
||||||
return fmt.Errorf("No user set")
|
return fmt.Errorf("No user set")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize URL
|
// Normalize URL
|
||||||
serverURL, err := utils.NormalizeURL(giteaURL)
|
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)
|
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{
|
login := config.Login{
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: serverURL.String(),
|
URL: serverURL.String(),
|
||||||
Token: token,
|
Token: token,
|
||||||
Insecure: insecure,
|
Insecure: insecure,
|
||||||
SSHKey: sshKey,
|
SSHKey: sshKey,
|
||||||
|
SSHCertPrincipal: sshCertPrincipal,
|
||||||
|
SSHKeyFingerprint: sshKeyFingerprint,
|
||||||
|
SSHAgent: sshAgent,
|
||||||
Created: time.Now().Unix(),
|
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 {
|
if login.Token, err = generateToken(login, user, passwd); err != nil {
|
||||||
return err
|
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
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
// Contains checks containment
|
// Contains checks containment
|
||||||
func Contains(haystack []string, needle string) bool {
|
func Contains(haystack []string, needle string) bool {
|
||||||
return IndexOf(haystack, needle) != -1
|
return IndexOf(haystack, needle) != -1
|
||||||
|
@ -18,3 +24,20 @@ func IndexOf(haystack []string, needle string) int {
|
||||||
}
|
}
|
||||||
return -1
|
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