mirror of
https://github.com/go-gitea/gitea
synced 2024-11-16 18:29:25 +01:00
Add ssh certificate support (#12281)
* Add ssh certificate support * Add ssh certificate support to builtin ssh * Write trusted-user-ca-keys.pem based on configuration * Update app.example.ini * Update templates/user/settings/keys_principal.tmpl Co-authored-by: silverwind <me@silverwind.io> * Remove unused locale string * Update options/locale/locale_en-US.ini Co-authored-by: silverwind <me@silverwind.io> * Update options/locale/locale_en-US.ini Co-authored-by: silverwind <me@silverwind.io> * Update models/ssh_key.go Co-authored-by: silverwind <me@silverwind.io> * Add missing creation of SSH.Rootpath * Update cheatsheet, example and locale strings * Update models/ssh_key.go Co-authored-by: zeripath <art27@cantab.net> * Update models/ssh_key.go Co-authored-by: zeripath <art27@cantab.net> * Update models/ssh_key.go Co-authored-by: zeripath <art27@cantab.net> * Update models/ssh_key.go Co-authored-by: zeripath <art27@cantab.net> * Update models/ssh_key.go * Optimizations based on feedback * Validate CA keys for external sshd * Add filename option and change default filename Add a SSH_TRUSTED_USER_CA_KEYS_FILENAME option which default is RUN_USER/.ssh/gitea-trusted-user-ca-keys.pem Do not write a file when SSH_TRUSTED_USER_CA_KEYS is empty. Add some more documentation. * Remove unneeded principalkey functions * Add blank line * Apply suggestions from code review Co-authored-by: zeripath <art27@cantab.net> * Add SSH_AUTHORIZED_PRINCIPALS_ALLOW option This adds a SSH_AUTHORIZED_PRINCIPALS_ALLOW which is default email,username this means that users only can add the principals that match their email or username. To allow anything the admin need to set the option anything. This allows for a safe default in gitea which protects against malicious users using other user's prinicipals. (before that user could set it). This commit also has some small other fixes from the last code review. * Rewrite principal keys file on user deletion * Use correct rewrite method * Set correct AuthorizedPrincipalsBackup default setting * Rewrite principalsfile when adding principals * Add update authorized_principals option to admin dashboard * Handle non-primary emails Signed-off-by: Andrew Thornton <art27@cantab.net> * Add the command actually to the dashboard template * Update models/ssh_key.go Co-authored-by: silverwind <me@silverwind.io> * By default do not show principal options unless there are CA keys set or they are explicitly set Signed-off-by: Andrew Thornton <art27@cantab.net> * allow settings when enabled * Fix typos in TrustedUserCAKeys path * Allow every CASignatureAlgorithms algorithm As this depends on the content of TrustedUserCAKeys we should allow all signature algorithms as admins can choose the specific algorithm on their signing CA * Update models/ssh_key.go Co-authored-by: Lauris BH <lauris@nix.lv> * Fix linting issue Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: Lauris BH <lauris@nix.lv> Co-authored-by: techknowlogick <matti@mdranta.net> Co-authored-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
parent
7eb8460132
commit
9066d09c57
@ -113,9 +113,12 @@ func runServ(c *cli.Context) error {
|
||||
if err != nil {
|
||||
fail("Internal error", "Failed to check provided key: %v", err)
|
||||
}
|
||||
if key.Type == models.KeyTypeDeploy {
|
||||
switch key.Type {
|
||||
case models.KeyTypeDeploy:
|
||||
println("Hi there! You've successfully authenticated with the deploy key named " + key.Name + ", but Gitea does not provide shell access.")
|
||||
} else {
|
||||
case models.KeyTypePrincipal:
|
||||
println("Hi there! You've successfully authenticated with the principal " + key.Content + ", but Gitea does not provide shell access.")
|
||||
default:
|
||||
println("Hi there, " + user.Name + "! You've successfully authenticated with the key named " + key.Name + ", but Gitea does not provide shell access.")
|
||||
}
|
||||
println("If this is unexpected, please log in with password and setup Gitea under another user.")
|
||||
|
@ -297,6 +297,9 @@ SSH_ROOT_PATH =
|
||||
; Gitea will create a authorized_keys file by default when it is not using the internal ssh server
|
||||
; If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
|
||||
SSH_CREATE_AUTHORIZED_KEYS_FILE = true
|
||||
; Gitea will create a authorized_principals file by default when it is not using the internal ssh server
|
||||
; If you intend to use the AuthorizedPrincipalsCommand functionality then you should turn this off.
|
||||
SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = true
|
||||
; For the built-in SSH server, choose the ciphers to support for SSH connections,
|
||||
; for system SSH this setting has no effect
|
||||
SSH_SERVER_CIPHERS = aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128
|
||||
@ -312,7 +315,26 @@ SSH_KEY_TEST_PATH =
|
||||
; Path to ssh-keygen, default is 'ssh-keygen' which means the shell is responsible for finding out which one to call.
|
||||
SSH_KEYGEN_PATH = ssh-keygen
|
||||
; Enable SSH Authorized Key Backup when rewriting all keys, default is true
|
||||
SSH_BACKUP_AUTHORIZED_KEYS = true
|
||||
SSH_AUTHORIZED_KEYS_BACKUP = true
|
||||
; Determines which principals to allow
|
||||
; - empty: if SSH_TRUSTED_USER_CA_KEYS is empty this will default to off, otherwise will default to email, username.
|
||||
; - off: Do not allow authorized principals
|
||||
; - email: the principal must match the user's email
|
||||
; - username: the principal must match the user's username
|
||||
; - anything: there will be no checking on the content of the principal
|
||||
SSH_AUTHORIZED_PRINCIPALS_ALLOW = email, username
|
||||
; Enable SSH Authorized Principals Backup when rewriting all keys, default is true
|
||||
SSH_AUTHORIZED_PRINCIPALS_BACKUP = true
|
||||
; Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication.
|
||||
; Multiple keys should be comma separated.
|
||||
; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
|
||||
; For more information see "TrustedUserCAKeys" in the sshd config manpages.
|
||||
SSH_TRUSTED_USER_CA_KEYS =
|
||||
; Absolute path of the `TrustedUserCaKeys` file gitea will manage.
|
||||
; Default this `RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem
|
||||
; If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your
|
||||
; sshd_config to point to this file. The official docker image will automatically work without further configuration.
|
||||
SSH_TRUSTED_USER_CA_KEYS_FILENAME =
|
||||
; Enable exposure of SSH clone URL to anonymous visitors, default is false
|
||||
SSH_EXPOSE_ANONYMOUS = false
|
||||
; Indicate whether to check minimum key size with corresponding type
|
||||
|
@ -13,6 +13,9 @@ HostKey /data/ssh/ssh_host_ecdsa_key
|
||||
HostKey /data/ssh/ssh_host_dsa_key
|
||||
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
AuthorizedPrincipalsFile .ssh/authorized_principals
|
||||
TrustedUserCAKeys /data/git/.ssh/gitea-trusted-user-ca-keys.pem
|
||||
CASignatureAlgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256,ssh-rsa
|
||||
|
||||
UseDNS no
|
||||
AllowAgentForwarding no
|
||||
|
@ -251,6 +251,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
|
||||
- `SSH_LISTEN_PORT`: **%(SSH\_PORT)s**: Port for the built-in SSH server.
|
||||
- `SSH_ROOT_PATH`: **~/.ssh**: Root path of SSH directory.
|
||||
- `SSH_CREATE_AUTHORIZED_KEYS_FILE`: **true**: Gitea will create a authorized_keys file by default when it is not using the internal ssh server. If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
|
||||
- `SSH_TRUSTED_USER_CA_KEYS`: **\<empty\>**: Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication. Multiple keys should be comma separated. E.g.`ssh-<algorithm> <key>` or `ssh-<algorithm> <key1>, ssh-<algorithm> <key2>`. For more information see `TrustedUserCAKeys` in the sshd config man pages. When empty no file will be created and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` will default to `off`.
|
||||
- `SSH_TRUSTED_USER_CA_KEYS_FILENAME`: **`RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem**: Absolute path of the `TrustedUserCaKeys` file gitea will manage. If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your sshd_config to point to this file. The official docker image will automatically work without further configuration.
|
||||
- `SSH_AUTHORIZED_PRINCIPALS_ALLOW`: **off** or **username, email**: \[off, username, email, anything\]: Specify the principals values that users are allowed to use as principal. When set to `anything` no checks are done on the principal string. When set to `off` authorized principal are not allowed to be set.
|
||||
- `SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE`: **false/true**: Gitea will create a authorized_principals file by default when it is not using the internal ssh server and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
|
||||
- `SSH_AUTHORIZED_PRINCIPALS_BACKUP`: **false/true**: Enable SSH Authorized Principals Backup when rewriting all keys, default is true if `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
|
||||
- `SSH_SERVER_CIPHERS`: **aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128**: For the built-in SSH server, choose the ciphers to support for SSH connections, for system SSH this setting has no effect.
|
||||
- `SSH_SERVER_KEY_EXCHANGES`: **diffie-hellman-group1-sha1, diffie-hellman-group14-sha1, ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521, curve25519-sha256@libssh.org**: For the built-in SSH server, choose the key exchange algorithms to support for SSH connections, for system SSH this setting has no effect.
|
||||
- `SSH_SERVER_MACS`: **hmac-sha2-256-etm@openssh.com, hmac-sha2-256, hmac-sha1, hmac-sha1-96**: For the built-in SSH server, choose the MACs to support for SSH connections, for system SSH this setting has no effect
|
||||
|
@ -40,6 +40,8 @@ const (
|
||||
tplCommentPrefix = `# gitea public key`
|
||||
tplCommand = "%s --config=%s serv key-%d"
|
||||
tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n"
|
||||
|
||||
authorizedPrincipalsFile = "authorized_principals"
|
||||
)
|
||||
|
||||
var sshOpLocker sync.Mutex
|
||||
@ -52,6 +54,8 @@ const (
|
||||
KeyTypeUser = iota + 1
|
||||
// KeyTypeDeploy specifies the deploy key
|
||||
KeyTypeDeploy
|
||||
// KeyTypePrincipal specifies the authorized principal key
|
||||
KeyTypePrincipal
|
||||
)
|
||||
|
||||
// PublicKey represents a user or deploy SSH public key.
|
||||
@ -401,6 +405,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key.Type == KeyTypePrincipal {
|
||||
continue
|
||||
}
|
||||
if _, err = f.WriteString(key.AuthorizedString()); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -571,6 +578,25 @@ func SearchPublicKeyByContent(content string) (*PublicKey, error) {
|
||||
return searchPublicKeyByContentWithEngine(x, content)
|
||||
}
|
||||
|
||||
func searchPublicKeyByContentExactWithEngine(e Engine, content string) (*PublicKey, error) {
|
||||
key := new(PublicKey)
|
||||
has, err := e.
|
||||
Where("content = ?", content).
|
||||
Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrKeyNotExist{}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SearchPublicKeyByContentExact searches content
|
||||
// and returns public key found.
|
||||
func SearchPublicKeyByContentExact(content string) (*PublicKey, error) {
|
||||
return searchPublicKeyByContentExactWithEngine(x, content)
|
||||
}
|
||||
|
||||
// SearchPublicKey returns a list of public keys matching the provided arguments.
|
||||
func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
|
||||
keys := make([]*PublicKey, 0, 5)
|
||||
@ -586,7 +612,7 @@ func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
|
||||
|
||||
// ListPublicKeys returns a list of public keys belongs to given user.
|
||||
func ListPublicKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
|
||||
sess := x.Where("owner_id = ?", uid)
|
||||
sess := x.Where("owner_id = ? AND type != ?", uid, KeyTypePrincipal)
|
||||
if listOptions.Page != 0 {
|
||||
sess = listOptions.setSessionPagination(sess)
|
||||
|
||||
@ -662,6 +688,10 @@ func DeletePublicKey(doer *User, id int64) (err error) {
|
||||
}
|
||||
sess.Close()
|
||||
|
||||
if key.Type == KeyTypePrincipal {
|
||||
return RewriteAllPrincipalKeys()
|
||||
}
|
||||
|
||||
return RewriteAllPublicKeys()
|
||||
}
|
||||
|
||||
@ -727,11 +757,10 @@ func RegeneratePublicKeys(t io.StringWriter) error {
|
||||
}
|
||||
|
||||
func regeneratePublicKeys(e Engine, t io.StringWriter) error {
|
||||
err := e.Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
|
||||
if err := e.Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
|
||||
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -1041,3 +1070,204 @@ func SearchDeployKeys(repoID int64, keyID int64, fingerprint string) ([]*DeployK
|
||||
}
|
||||
return keys, x.Where(cond).Find(&keys)
|
||||
}
|
||||
|
||||
// __________ .__ .__ .__
|
||||
// \______ _______|__| ____ ____ |_____________ | | ______
|
||||
// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/
|
||||
// | | | | \| | | \ \___| | |_> / __ \| |__\___ \
|
||||
// |____| |__| |__|___| /\___ |__| __(____ |____/____ >
|
||||
// \/ \/ |__| \/ \/
|
||||
|
||||
// AddPrincipalKey adds new principal to database and authorized_principals file.
|
||||
func AddPrincipalKey(ownerID int64, content string, loginSourceID int64) (*PublicKey, error) {
|
||||
sess := x.NewSession()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Principals cannot be duplicated.
|
||||
has, err := sess.
|
||||
Where("content = ? AND type = ?", content, KeyTypePrincipal).
|
||||
Get(new(PublicKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if has {
|
||||
return nil, ErrKeyAlreadyExist{0, "", content}
|
||||
}
|
||||
|
||||
key := &PublicKey{
|
||||
OwnerID: ownerID,
|
||||
Name: content,
|
||||
Content: content,
|
||||
Mode: AccessModeWrite,
|
||||
Type: KeyTypePrincipal,
|
||||
LoginSourceID: loginSourceID,
|
||||
}
|
||||
if err = addPrincipalKey(sess, key); err != nil {
|
||||
return nil, fmt.Errorf("addKey: %v", err)
|
||||
}
|
||||
|
||||
if err = sess.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sess.Close()
|
||||
|
||||
return key, RewriteAllPrincipalKeys()
|
||||
}
|
||||
|
||||
func addPrincipalKey(e Engine, key *PublicKey) (err error) {
|
||||
// Save Key representing a principal.
|
||||
if _, err = e.Insert(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines
|
||||
func CheckPrincipalKeyString(user *User, content string) (_ string, err error) {
|
||||
if setting.SSH.Disabled {
|
||||
return "", ErrSSHDisabled{}
|
||||
}
|
||||
|
||||
content = strings.TrimSpace(content)
|
||||
if strings.ContainsAny(content, "\r\n") {
|
||||
return "", errors.New("only a single line with a single principal please")
|
||||
}
|
||||
|
||||
// check all the allowed principals, email, username or anything
|
||||
// if any matches, return ok
|
||||
for _, v := range setting.SSH.AuthorizedPrincipalsAllow {
|
||||
switch v {
|
||||
case "anything":
|
||||
return content, nil
|
||||
case "email":
|
||||
emails, err := GetEmailAddresses(user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, email := range emails {
|
||||
if !email.IsActivated {
|
||||
continue
|
||||
}
|
||||
if content == email.Email {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "username":
|
||||
if content == user.Name {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("didn't match allowed principals: %s", setting.SSH.AuthorizedPrincipalsAllow)
|
||||
}
|
||||
|
||||
// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
|
||||
// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function
|
||||
// outside any session scope independently.
|
||||
func RewriteAllPrincipalKeys() error {
|
||||
return rewriteAllPrincipalKeys(x)
|
||||
}
|
||||
|
||||
func rewriteAllPrincipalKeys(e Engine) error {
|
||||
// Don't rewrite key if internal server
|
||||
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
sshOpLocker.Lock()
|
||||
defer sshOpLocker.Unlock()
|
||||
|
||||
if setting.SSH.RootPath != "" {
|
||||
// First of ensure that the RootPath is present, and if not make it with 0700 permissions
|
||||
// This of course doesn't guarantee that this is the right directory for authorized_keys
|
||||
// but at least if it's supposed to be this directory and it doesn't exist and we're the
|
||||
// right user it will at least be created properly.
|
||||
err := os.MkdirAll(setting.SSH.RootPath, 0700)
|
||||
if err != nil {
|
||||
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
|
||||
tmpPath := fPath + ".tmp"
|
||||
t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
t.Close()
|
||||
os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if setting.SSH.AuthorizedPrincipalsBackup && com.IsExist(fPath) {
|
||||
bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
|
||||
if err = com.Copy(fPath, bakPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := regeneratePrincipalKeys(e, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Close()
|
||||
return os.Rename(tmpPath, fPath)
|
||||
}
|
||||
|
||||
// ListPrincipalKeys returns a list of principals belongs to given user.
|
||||
func ListPrincipalKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
|
||||
sess := x.Where("owner_id = ? AND type = ?", uid, KeyTypePrincipal)
|
||||
if listOptions.Page != 0 {
|
||||
sess = listOptions.setSessionPagination(sess)
|
||||
|
||||
keys := make([]*PublicKey, 0, listOptions.PageSize)
|
||||
return keys, sess.Find(&keys)
|
||||
}
|
||||
|
||||
keys := make([]*PublicKey, 0, 5)
|
||||
return keys, sess.Find(&keys)
|
||||
}
|
||||
|
||||
// RegeneratePrincipalKeys regenerates the authorized_principals file
|
||||
func RegeneratePrincipalKeys(t io.StringWriter) error {
|
||||
return regeneratePrincipalKeys(x, t)
|
||||
}
|
||||
|
||||
func regeneratePrincipalKeys(e Engine, t io.StringWriter) error {
|
||||
if err := e.Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
|
||||
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
|
||||
if com.IsExist(fPath) {
|
||||
f, err := os.Open(fPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, tplCommentPrefix) {
|
||||
scanner.Scan()
|
||||
continue
|
||||
}
|
||||
_, err = t.WriteString(line + "\n")
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -1254,6 +1254,10 @@ func deleteUser(e *xorm.Session, u *User) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = rewriteAllPrincipalKeys(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ***** END: PublicKey *****
|
||||
|
||||
// ***** START: GPGPublicKey *****
|
||||
|
@ -67,6 +67,16 @@ func registerRewriteAllPublicKeys() {
|
||||
})
|
||||
}
|
||||
|
||||
func registerRewriteAllPrincipalKeys() {
|
||||
RegisterTaskFatal("resync_all_sshprincipals", &BaseConfig{
|
||||
Enabled: false,
|
||||
RunAtStart: false,
|
||||
Schedule: "@every 72h",
|
||||
}, func(_ context.Context, _ *models.User, _ Config) error {
|
||||
return models.RewriteAllPrincipalKeys()
|
||||
})
|
||||
}
|
||||
|
||||
func registerRepositoryUpdateHook() {
|
||||
RegisterTaskFatal("resync_all_hooks", &BaseConfig{
|
||||
Enabled: false,
|
||||
@ -112,6 +122,7 @@ func initExtendedTasks() {
|
||||
registerDeleteRepositoryArchives()
|
||||
registerGarbageCollectRepositories()
|
||||
registerRewriteAllPublicKeys()
|
||||
registerRewriteAllPrincipalKeys()
|
||||
registerRepositoryUpdateHook()
|
||||
registerReinitMissingRepositories()
|
||||
registerDeleteMissingRepositories()
|
||||
|
@ -28,6 +28,7 @@ import (
|
||||
|
||||
shellquote "github.com/kballard/go-shellquote"
|
||||
"github.com/unknwon/com"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
ini "gopkg.in/ini.v1"
|
||||
"strk.kbt.io/projects/go/libravatar"
|
||||
)
|
||||
@ -117,10 +118,17 @@ var (
|
||||
KeyTestPath string `ini:"SSH_KEY_TEST_PATH"`
|
||||
KeygenPath string `ini:"SSH_KEYGEN_PATH"`
|
||||
AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
|
||||
AuthorizedPrincipalsBackup bool `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"`
|
||||
MinimumKeySizeCheck bool `ini:"-"`
|
||||
MinimumKeySizes map[string]int `ini:"-"`
|
||||
CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
|
||||
CreateAuthorizedPrincipalsFile bool `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"`
|
||||
ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"`
|
||||
AuthorizedPrincipalsAllow []string `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"`
|
||||
AuthorizedPrincipalsEnabled bool `ini:"-"`
|
||||
TrustedUserCAKeys []string `ini:"SSH_TRUSTED_USER_CA_KEYS"`
|
||||
TrustedUserCAKeysFile string `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"`
|
||||
TrustedUserCAKeysParsed []gossh.PublicKey `ini:"-"`
|
||||
}{
|
||||
Disabled: false,
|
||||
StartBuiltinServer: false,
|
||||
@ -672,12 +680,38 @@ func NewContext() {
|
||||
SSH.StartBuiltinServer = false
|
||||
}
|
||||
|
||||
trustedUserCaKeys := sec.Key("SSH_TRUSTED_USER_CA_KEYS").Strings(",")
|
||||
for _, caKey := range trustedUserCaKeys {
|
||||
pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey))
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err)
|
||||
}
|
||||
|
||||
SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey)
|
||||
}
|
||||
if len(trustedUserCaKeys) > 0 {
|
||||
// Set the default as email,username otherwise we can leave it empty
|
||||
sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email")
|
||||
} else {
|
||||
sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off")
|
||||
}
|
||||
|
||||
SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(","))
|
||||
|
||||
if !SSH.Disabled && !SSH.StartBuiltinServer {
|
||||
if err := os.MkdirAll(SSH.RootPath, 0700); err != nil {
|
||||
log.Fatal("Failed to create '%s': %v", SSH.RootPath, err)
|
||||
} else if err = os.MkdirAll(SSH.KeyTestPath, 0644); err != nil {
|
||||
log.Fatal("Failed to create '%s': %v", SSH.KeyTestPath, err)
|
||||
}
|
||||
|
||||
if len(trustedUserCaKeys) > 0 && SSH.AuthorizedPrincipalsEnabled {
|
||||
fname := sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem"))
|
||||
if err := ioutil.WriteFile(fname,
|
||||
[]byte(strings.Join(trustedUserCaKeys, "\n")), 0600); err != nil {
|
||||
log.Fatal("Failed to create '%s': %v", fname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck)
|
||||
@ -689,8 +723,17 @@ func NewContext() {
|
||||
delete(SSH.MinimumKeySizes, strings.ToLower(key.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(true)
|
||||
SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true)
|
||||
|
||||
SSH.AuthorizedPrincipalsBackup = false
|
||||
SSH.CreateAuthorizedPrincipalsFile = false
|
||||
if SSH.AuthorizedPrincipalsEnabled {
|
||||
SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true)
|
||||
SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true)
|
||||
}
|
||||
|
||||
SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false)
|
||||
|
||||
if err = Cfg.Section("oauth2").MapTo(&OAuth2); err != nil {
|
||||
@ -944,6 +987,38 @@ func NewContext() {
|
||||
}
|
||||
}
|
||||
|
||||
func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) {
|
||||
anything := false
|
||||
email := false
|
||||
username := false
|
||||
for _, value := range values {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
switch v {
|
||||
case "off":
|
||||
return []string{"off"}, false
|
||||
case "email":
|
||||
email = true
|
||||
case "username":
|
||||
username = true
|
||||
case "anything":
|
||||
anything = true
|
||||
}
|
||||
}
|
||||
if anything {
|
||||
return []string{"anything"}, true
|
||||
}
|
||||
|
||||
authorizedPrincipalsAllow := []string{}
|
||||
if username {
|
||||
authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username")
|
||||
}
|
||||
if email {
|
||||
authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email")
|
||||
}
|
||||
|
||||
return authorizedPrincipalsAllow, true
|
||||
}
|
||||
|
||||
func loadInternalToken(sec *ini.Section) string {
|
||||
uri := sec.Key("INTERNAL_TOKEN_URI").String()
|
||||
if len(uri) == 0 {
|
||||
|
@ -5,6 +5,7 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@ -136,6 +137,52 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// check if we have a certificate
|
||||
if cert, ok := key.(*gossh.Certificate); ok {
|
||||
if len(setting.SSH.TrustedUserCAKeys) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// look for the exact principal
|
||||
for _, principal := range cert.ValidPrincipals {
|
||||
pkey, err := models.SearchPublicKeyByContentExact(principal)
|
||||
if err != nil {
|
||||
log.Error("SearchPublicKeyByContentExact: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if models.IsErrKeyNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
c := &gossh.CertChecker{
|
||||
IsUserAuthority: func(auth gossh.PublicKey) bool {
|
||||
for _, k := range setting.SSH.TrustedUserCAKeysParsed {
|
||||
if bytes.Equal(auth.Marshal(), k.Marshal()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
// check the CA of the cert
|
||||
if !c.IsUserAuthority(cert.SignatureKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
// validate the cert for this principal
|
||||
if err := c.CheckCert(principal, cert); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx.SetValue(giteaKeyID, pkey.ID)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
pkey, err := models.SearchPublicKeyByContent(strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
|
||||
if err != nil {
|
||||
log.Error("SearchPublicKeyByContent: %v", err)
|
||||
|
@ -383,6 +383,7 @@ cannot_add_org_to_team = An organization cannot be added as a team member.
|
||||
|
||||
invalid_ssh_key = Can not verify your SSH key: %s
|
||||
invalid_gpg_key = Can not verify your GPG key: %s
|
||||
invalid_ssh_principal = Invalid principal: %s
|
||||
unable_verify_ssh_key = "Can not verify the SSH key; double-check it for mistakes."
|
||||
auth_failed = Authentication failed: %v
|
||||
|
||||
@ -501,9 +502,11 @@ keep_email_private_popup = Your email address will be hidden from other users.
|
||||
openid_desc = OpenID lets you delegate authentication to an external provider.
|
||||
|
||||
manage_ssh_keys = Manage SSH Keys
|
||||
manage_ssh_principals = Manage SSH Certificate Principals
|
||||
manage_gpg_keys = Manage GPG Keys
|
||||
add_key = Add Key
|
||||
ssh_desc = These public SSH keys are associated with your account. The corresponding private keys allow full access to your repositories.
|
||||
principal_desc = These SSH certificate principals are associated with your account and allow full access to your repositories.
|
||||
gpg_desc = These public GPG keys are associated with your account. Keep your private keys safe as they allow commits to be verified.
|
||||
ssh_helper = <strong>Need help?</strong> Have a look at GitHub's guide to <a href="%s">create your own SSH keys</a> or solve <a href="%s">common problems</a> you may encounter using SSH.
|
||||
gpg_helper = <strong>Need help?</strong> Have a look at GitHub's guide <a href="%s">about GPG</a>.
|
||||
@ -511,23 +514,30 @@ add_new_key = Add SSH Key
|
||||
add_new_gpg_key = Add GPG Key
|
||||
key_content_ssh_placeholder = Begins with 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', or 'ecdsa-sha2-nistp521'
|
||||
key_content_gpg_placeholder = Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'
|
||||
add_new_principal = Add Principal
|
||||
ssh_key_been_used = This SSH key has already been added to the server.
|
||||
ssh_key_name_used = An SSH key with same name is already added to your account.
|
||||
ssh_key_name_used = An SSH key with same name already exists on your account.
|
||||
ssh_principal_been_used = This principal has already been added to the server.
|
||||
gpg_key_id_used = A public GPG key with same ID already exists.
|
||||
gpg_no_key_email_found = This GPG key is not usable with any email address associated with your account.
|
||||
subkeys = Subkeys
|
||||
key_id = Key ID
|
||||
key_name = Key Name
|
||||
key_content = Content
|
||||
principal_content = Content
|
||||
add_key_success = The SSH key '%s' has been added.
|
||||
add_gpg_key_success = The GPG key '%s' has been added.
|
||||
add_principal_success = The SSH certificate principal '%s' has been added.
|
||||
delete_key = Remove
|
||||
ssh_key_deletion = Remove SSH Key
|
||||
gpg_key_deletion = Remove GPG Key
|
||||
ssh_principal_deletion = Remove SSH Certificate Principal
|
||||
ssh_key_deletion_desc = Removing an SSH key revokes its access to your account. Continue?
|
||||
gpg_key_deletion_desc = Removing a GPG key un-verifies commits signed by it. Continue?
|
||||
ssh_principal_deletion_desc = Removing a SSH Certificate Principal revokes its access to your account. Continue?
|
||||
ssh_key_deletion_success = The SSH key has been removed.
|
||||
gpg_key_deletion_success = The GPG key has been removed.
|
||||
ssh_principal_deletion_success = The principal has been removed.
|
||||
add_on = Added on
|
||||
valid_until = Valid until
|
||||
valid_forever = Valid forever
|
||||
@ -537,10 +547,10 @@ can_read_info = Read
|
||||
can_write_info = Write
|
||||
key_state_desc = This key has been used in the last 7 days
|
||||
token_state_desc = This token has been used in the last 7 days
|
||||
principal_state_desc = This principal has been used in the last 7 days
|
||||
show_openid = Show on profile
|
||||
hide_openid = Hide from profile
|
||||
ssh_disabled = SSH Disabled
|
||||
|
||||
manage_social = Manage Associated Social Accounts
|
||||
social_desc = These social accounts are linked to your Gitea account. Make sure you recognize all of them as they can be used to sign in to your Gitea account.
|
||||
unbind = Unlink
|
||||
@ -1994,6 +2004,8 @@ dashboard.update_migration_poster_id = Update migration poster IDs
|
||||
dashboard.git_gc_repos = Garbage collect all repositories
|
||||
dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys.
|
||||
dashboard.resync_all_sshkeys.desc = (Not needed for the built-in SSH server.)
|
||||
dashboard.resync_all_sshprincipals = Update the '.ssh/authorized_principals' file with Gitea SSH principals.
|
||||
dashboard.resync_all_sshprincipals.desc = (Not needed for the built-in SSH server.)
|
||||
dashboard.resync_all_hooks = Resynchronize pre-receive, update and post-receive hooks of all repositories.
|
||||
dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for which records exist
|
||||
dashboard.sync_external_users = Synchronize external user data
|
||||
|
@ -46,7 +46,7 @@ func ServNoCommand(ctx *macaron.Context) {
|
||||
}
|
||||
results.Key = key
|
||||
|
||||
if key.Type == models.KeyTypeUser {
|
||||
if key.Type == models.KeyTypeUser || key.Type == models.KeyTypePrincipal {
|
||||
user, err := models.GetUserByID(key.OwnerID)
|
||||
if err != nil {
|
||||
if models.IsErrUserNotExist(err) {
|
||||
|
@ -22,6 +22,8 @@ func Keys(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings")
|
||||
ctx.Data["PageIsSettingsKeys"] = true
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
|
||||
ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
|
||||
|
||||
loadKeysData(ctx)
|
||||
|
||||
@ -32,6 +34,9 @@ func Keys(ctx *context.Context) {
|
||||
func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings")
|
||||
ctx.Data["PageIsSettingsKeys"] = true
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
|
||||
ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
|
||||
|
||||
if ctx.HasError() {
|
||||
loadKeysData(ctx)
|
||||
@ -40,6 +45,32 @@ func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
|
||||
return
|
||||
}
|
||||
switch form.Type {
|
||||
case "principal":
|
||||
content, err := models.CheckPrincipalKeyString(ctx.User, form.Content)
|
||||
if err != nil {
|
||||
if models.IsErrSSHDisabled(err) {
|
||||
ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
|
||||
} else {
|
||||
ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error()))
|
||||
}
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
return
|
||||
}
|
||||
if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil {
|
||||
ctx.Data["HasPrincipalError"] = true
|
||||
switch {
|
||||
case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err):
|
||||
loadKeysData(ctx)
|
||||
|
||||
ctx.Data["Err_Content"] = true
|
||||
ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form)
|
||||
default:
|
||||
ctx.ServerError("AddPrincipalKey", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
case "gpg":
|
||||
keys, err := models.AddGPGKey(ctx.User.ID, form.Content)
|
||||
if err != nil {
|
||||
@ -134,6 +165,12 @@ func DeleteKey(ctx *context.Context) {
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
|
||||
}
|
||||
case "principal":
|
||||
if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil {
|
||||
ctx.Flash.Error("DeletePublicKey: " + err.Error())
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
|
||||
}
|
||||
default:
|
||||
ctx.Flash.Warning("Function not implemented")
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
@ -157,4 +194,11 @@ func loadKeysData(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Data["GPGKeys"] = gpgkeys
|
||||
|
||||
principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListPrincipalKeys", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Principals"] = principals
|
||||
}
|
||||
|
@ -42,6 +42,11 @@
|
||||
<td><button type="submit" class="ui green button" name="op" value="resync_all_sshkeys">{{svg "octicon-play"}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr>
|
||||
<td>{{.i18n.Tr "admin.dashboard.resync_all_sshprincipals"}}<br/>
|
||||
{{.i18n.Tr "admin.dashboard.resync_all_sshprincipals.desc"}}</td>
|
||||
<td><button type="submit" class="ui green button" name="op" value="resync_all_sshprincipals">{{svg "octicon-play" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{.i18n.Tr "admin.dashboard.resync_all_hooks"}}</td>
|
||||
<td><button type="submit" class="ui green button" name="op" value="resync_all_hooks">{{svg "octicon-play"}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
|
||||
|
@ -4,6 +4,7 @@
|
||||
<div class="ui container">
|
||||
{{template "base/alert" .}}
|
||||
{{template "user/settings/keys_ssh" .}}
|
||||
{{template "user/settings/keys_principal" .}}
|
||||
{{template "user/settings/keys_gpg" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
67
templates/user/settings/keys_principal.tmpl
Normal file
67
templates/user/settings/keys_principal.tmpl
Normal file
@ -0,0 +1,67 @@
|
||||
{{if .AllowPrincipals}}
|
||||
<h4 class="ui top attached header">
|
||||
{{.i18n.Tr "settings.manage_ssh_principals"}}
|
||||
<div class="ui right">
|
||||
{{if not .DisableSSH}}
|
||||
<div class="ui blue tiny show-panel button" data-panel="#add-ssh-principal-panel">{{.i18n.Tr "settings.add_new_principal"}}</div>
|
||||
{{else}}
|
||||
<div class="ui blue tiny button disabled">{{.i18n.Tr "settings.ssh_disabled"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="ui key list">
|
||||
<div class="item">
|
||||
{{.i18n.Tr "settings.principal_desc"}}
|
||||
</div>
|
||||
{{range .Principals}}
|
||||
<div class="item">
|
||||
<div class="right floated content">
|
||||
<button class="ui red tiny button delete-button" id="delete-principal" data-url="{{$.Link}}/delete?type=principal" data-id="{{.ID}}">
|
||||
{{$.i18n.Tr "settings.delete_key"}}
|
||||
</button>
|
||||
</div>
|
||||
<i class="big send icon {{if .HasRecentActivity}}green{{end}}" {{if .HasRecentActivity}}data-content="{{$.i18n.Tr "settings.principal_state_desc"}}" data-variation="inverted tiny"{{end}}></i>
|
||||
<div class="content">
|
||||
<strong>{{.Name}}</strong>
|
||||
<div class="activity meta">
|
||||
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> — {{svg "octicon-info" 16}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div {{if not .HasPrincipalError}}class="hide"{{end}} id="add-ssh-principal-panel">
|
||||
<h4 class="ui top attached header">
|
||||
{{.i18n.Tr "settings.add_new_principal"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field {{if .Err_Content}}error{{end}}">
|
||||
<label for="content">{{.i18n.Tr "settings.principal_content"}}</label>
|
||||
<input id="ssh-principal-content" name="content" value="{{.content}}" autofocus required>
|
||||
</div>
|
||||
<input name="title" type="hidden" value="principal">
|
||||
<input name="type" type="hidden" value="principal">
|
||||
<button class="ui green button">
|
||||
{{.i18n.Tr "settings.add_new_principal"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui small basic delete modal" id="delete-principal">
|
||||
<div class="ui icon header">
|
||||
<i class="trash icon"></i>
|
||||
{{.i18n.Tr "settings.ssh_principal_deletion"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.i18n.Tr "settings.ssh_principal_deletion_desc"}}</p>
|
||||
</div>
|
||||
{{template "base/delete_modal_actions" .}}
|
||||
</div>
|
||||
{{end}}
|
Loading…
Reference in New Issue
Block a user