diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..3d8f98ec
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,16 @@
+on: [push, pull_request]
+name: Test
+jobs:
+ test:
+ strategy:
+ matrix:
+ go-version: [1.20.x]
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.go-version }}
+ - uses: actions/checkout@v3
+ - run: make test
+
diff --git a/Makefile b/Makefile
index 314b930e..054b34b6 100644
--- a/Makefile
+++ b/Makefile
@@ -10,18 +10,29 @@ endif
BINARY=keymaster
# These are the values we want to pass for Version and BuildTime
-VERSION=1.9.0
+VERSION=1.14.0
#BUILD_TIME=`date +%FT%T%z`
# Setup the -ldflags option for go build here, interpolate the variable values
#LDFLAGS=-ldflags "-X github.com/ariejan/roll/core.Version=${VERSION} -X github.com/ariejan/roll/core.BuildTime=${BUILD_TIME}"
-all: init-config-host
- cd $(GOPATH)/src; go install -ldflags "-X main.Version=${VERSION}" github.com/Cloud-Foundations/keymaster/cmd/*
+all: init-config-host cmd/keymasterd/binData.go
+ cd cmd/keymaster; go install -ldflags "-X main.Version=${VERSION}"
+ cd cmd/keymasterd; go install -ldflags "-X main.Version=${VERSION}"
+ cd cmd/keymaster-unlocker; go install -ldflags "-X main.Version=${VERSION}"
+ cd cmd/keymaster-eventmond; go install -ldflags "-X main.Version=${VERSION}"
-win-client:
- cd $(GOPATH)\src && go install -ldflags "-X main.Version=${VERSION}" github.com\Cloud-Foundations\keymaster\cmd\keymaster
- cd $(GOPATH)\src\github.com\Cloud-Foundations\keymaster\cmd\keymaster && go test -v ./...
+build: cmd/keymasterd/binData.go
+ go build -ldflags "-X main.Version=${VERSION}" -o bin/ ./...
+
+cmd/keymasterd/binData.go:
+ -go-bindata -fs -o cmd/keymasterd/binData.go -prefix cmd/keymasterd/data cmd/keymasterd/data/...
+
+win-client: client-test
+ go build -ldflags "-X main.Version=${VERSION}" -o bin .\cmd\keymaster\
+
+client-test:
+ go test -v ./cmd/keymaster/...
get-deps: init-config-host
go get -t ./...
@@ -38,7 +49,10 @@ ${BINARY}-${VERSION}.tar.gz:
rsync -av --exclude="config.yml" --exclude="*.pem" --exclude="*.out" lib/ ${BINARY}-${VERSION}/lib/
rsync -av --exclude="config.yml" --exclude="*.pem" --exclude="*.out" --exclude="*.key" cmd/ ${BINARY}-${VERSION}/cmd/
rsync -av misc/ ${BINARY}-${VERSION}/misc/
- cp LICENSE Makefile keymaster.spec README.md ${BINARY}-${VERSION}/
+ rsync -av proto/ ${BINARY}-${VERSION}/proto/
+ rsync -av keymasterd/ ${BINARY}-${VERSION}/keymasterd/
+ rsync -av eventmon/ ${BINARY}-${VERSION}/eventmon/
+ cp -p LICENSE Makefile keymaster.spec README.md go.mod go.sum ${BINARY}-${VERSION}/
tar -cvzf ${BINARY}-${VERSION}.tar.gz ${BINARY}-${VERSION}/
rm -rf ${BINARY}-${VERSION}/
diff --git a/README.md b/README.md
index fb8ac83e..437aeff6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Keymaster
-[![Build Status](https://travis-ci.org/Cloud-Foundations/keymaster.svg?branch=master)](https://travis-ci.org/Cloud-Foundations/keymaster)
+
+[![Build Status](https://github.com/Cloud-Foundations/keymaster/actions/workflows/test.yml/badge.svg?query=branch%3Amaster)](https://github.com/Cloud-Foundations/keymaster/actions/workflows/test.yml?query=branch%3Amaster)
[![Coverage Status](https://coveralls.io/repos/github/Cloud-Foundations/keymaster/badge.svg?branch=master)](https://coveralls.io/github/Cloud-Foundations/keymaster?branch=master)
-[![Go Report Card](https://goreportcard.com/badge/github.com/Cloud-Foundations/keymaster)](https://goreportcard.com/report/github.com/Cloud-Foundations/keymaster)
Keymaster is usable short-term certificate based identity system. With a primary goal to be a single-sign-on (with optional second factor with [Symantec VIP](https://vip.symantec.com/), [U2F](https://fidoalliance.org/specifications/overview/) tokens or [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm) compatible apps ([FreeOTP](https://freeotp.github.io/)/google authenticator ) ) for CLI operations (both SSHD and TLS).
@@ -35,7 +35,7 @@ Pre-build binaries (both RPM and DEB) can be found here: [releases page](https:/
* make
* gcc
-For Windows (both gcc and gnu-make) use: [TDM-GCC (64 bit)](https://sourceforge.net/projects/tdm-gcc/).
+For Windows (both gcc and gnu-make) use: [TDM-GCC (64 bit)](https://sourceforge.net/projects/tdm-gcc/). Recent windows builds fail when using TDM-GCC 5.x. Successful builds are known with golang 1.16.X and gcc 10.X.
#### Building
1. make get-deps
@@ -98,7 +98,7 @@ patents and contracts.
## LICENSE
Copyright 2016-2019 Symantec Corporation.
-Copyright 2019-2020 Cloud-Foundations.org
+Copyright 2019-2021 Cloud-Foundations.org
Licensed under the Apache License, Version 2.0 (the “License”); you
may not use this file except in compliance with the License.
@@ -110,3 +110,7 @@ License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for
the specific language governing permissions and limitations under the
License.
+
+## Versioning
+Keymaster versions follow the [Sementic Versioning](https://semver.org/)
+guidelines.
diff --git a/cmd/keymaster-unlocker/main.go b/cmd/keymaster-unlocker/main.go
index 7aec87ae..8f5a9e7b 100644
--- a/cmd/keymaster-unlocker/main.go
+++ b/cmd/keymaster-unlocker/main.go
@@ -69,7 +69,8 @@ func main() {
logger.Fatal(err)
}
// Setup HTTPS clients.
- tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
+ tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS12}
tlsConfig.BuildNameToCertificate()
clients := makeClients(addrs, tlsConfig)
var password string
diff --git a/cmd/keymaster/main.go b/cmd/keymaster/main.go
index cd8a172f..0ab5a1e4 100644
--- a/cmd/keymaster/main.go
+++ b/cmd/keymaster/main.go
@@ -1,9 +1,6 @@
package main
import (
- "crypto/ed25519"
- "crypto/rand"
- "crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
@@ -20,20 +17,27 @@ import (
"strings"
"time"
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+
"github.com/Cloud-Foundations/Dominator/lib/log/cmdlogger"
"github.com/Cloud-Foundations/Dominator/lib/net/rrdialer"
"github.com/Cloud-Foundations/golib/pkg/log"
+ "github.com/Cloud-Foundations/keymaster/lib/client/aws_role"
"github.com/Cloud-Foundations/keymaster/lib/client/config"
libnet "github.com/Cloud-Foundations/keymaster/lib/client/net"
"github.com/Cloud-Foundations/keymaster/lib/client/sshagent"
"github.com/Cloud-Foundations/keymaster/lib/client/twofa"
"github.com/Cloud-Foundations/keymaster/lib/client/twofa/u2f"
"github.com/Cloud-Foundations/keymaster/lib/client/util"
+ "github.com/Cloud-Foundations/keymaster/lib/client/webauth"
)
-const DefaultSSHKeysLocation = "/.ssh/"
-const DefaultTLSKeysLocation = "/.ssl/"
-const DefaultTMPKeysLocation = "/.keymaster/"
+const (
+ DefaultSSHKeysLocation = "/.ssh/"
+ DefaultTLSKeysLocation = "/.ssl/"
+ keymasterSubdir = ".keymaster"
+)
const userAgentAppName = "keymaster"
const defaultVersionNumber = "No version provided"
@@ -47,14 +51,22 @@ var (
)
var (
- configFilename = flag.String("config", filepath.Join(getUserHomeDir(), ".keymaster", "client_config.yml"), "The filename of the configuration")
- rootCAFilename = flag.String("rootCAFilename", "", "(optional) name for using non OS root CA to verify TLS connections")
- configHost = flag.String("configHost", "", "Get a bootstrap config from this host")
- cliUsername = flag.String("username", "", "username for keymaster")
- checkDevices = flag.Bool("checkDevices", false, "CheckU2F devices in your system")
- cliFilePrefix = flag.String("fileprefix", "", "Prefix for the output files")
+ configFilename = flag.String("config",
+ filepath.Join(getUserHomeDir(), keymasterSubdir, "client_config.yml"),
+ "The filename of the configuration")
+ rootCAFilename = flag.String("rootCAFilename", "",
+ "(optional) name for using non OS root CA to verify TLS connections")
+ configHost = flag.String("configHost", "",
+ "Get a bootstrap config from this host")
+ cliUsername = flag.String("username", "", "username for keymaster")
+ checkDevices = flag.Bool("checkDevices", false,
+ "CheckU2F devices in your system")
+ cliFilePrefix = flag.String("fileprefix", "",
+ "Prefix for the output files")
roundRobinDialer = flag.Bool("roundRobinDialer", false,
"If true, use the smart round-robin dialer")
+ webauthBrowser = flag.String("webauthBrowser", "",
+ "Browser command to use for webauth")
FilePrefix = "keymaster"
)
@@ -189,15 +201,102 @@ func backgroundConnectToAnyKeymasterServer(targetUrls []string, client *http.Cli
const rsaKeySize = 2048
+func generateAwsRoleCert(homeDir string,
+ configContents config.AppConfigFile,
+ client *http.Client,
+ logger log.DebugLogger) error {
+ signers := makeSigners()
+ // Initialise the client connection.
+ targetURLs := strings.Split(configContents.Base.Gen_Cert_URLS, ",")
+ err := backgroundConnectToAnyKeymasterServer(targetURLs, client, logger)
+ if err != nil {
+ return err
+ }
+ if err := makeDirs(homeDir); err != nil {
+ return err
+ }
+ tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix)
+ if err := signers.Wait(); err != nil {
+ return err
+ }
+ manager, err := aws_role.NewManager(aws_role.Params{
+ KeymasterServer: targetURLs[0],
+ Logger: logger,
+ HttpClient: client,
+ Signer: signers.X509Rsa,
+ })
+ if err != nil {
+ return err
+ }
+ encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(signers.X509Rsa)
+ if err != nil {
+ return err
+ }
+ err = ioutil.WriteFile(
+ tlsKeyPath+".key",
+ pem.EncodeToMemory(&pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: encodedx509Signer}),
+ 0600)
+ if err != nil {
+ return err
+ }
+ x509CertPath := tlsKeyPath + ".cert"
+ certPEM, _, err := manager.GetRoleCertificate()
+ if err != nil {
+ return err
+ }
+ if err := ioutil.WriteFile(x509CertPath, certPEM, 0644); err != nil {
+ return errors.New("Could not write ssh cert")
+ }
+ for {
+ logger.Println("starting loop waiting for certificate refreshes")
+ manager.WaitForRefresh()
+ certPEM, _, err := manager.GetRoleCertificate()
+ if err != nil {
+ return err
+ }
+ tempPath := x509CertPath + "~"
+ err = ioutil.WriteFile(tempPath, certPEM, 0644)
+ if err != nil {
+ return errors.New("Could not write ssh cert")
+ }
+ defer os.Remove(tempPath)
+ return os.Rename(tempPath, x509CertPath)
+
+ }
+ return nil
+}
+
// Beware, this function has inverted path.... at the beggining
func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte,
signer interface{},
filePrefix string,
userName string,
privateKeyPath string,
+ confirmBeforeUse bool,
logger log.DebugLogger) (err error) {
+
+ pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certText)
+ if err != nil {
+ logger.Println(err)
+ return err
+ }
+ sshCert, ok := pubKey.(*ssh.Certificate)
+ if !ok {
+ return fmt.Errorf("It is not a certificate")
+ }
+ comment := filePrefix + "-" + userName
+ keyToAdd := agent.AddedKey{
+ PrivateKey: signer,
+ Certificate: sshCert,
+ Comment: comment,
+ LifetimeSecs: uint32((*twofa.Duration).Seconds()),
+ ConfirmBeforeUse: confirmBeforeUse,
+ }
+
//comment should be based on key type?
- err = sshagent.UpsertCertIntoAgent(certText, signer, filePrefix+"-"+userName, uint32((*twofa.Duration).Seconds()), logger)
+ err = sshagent.WithAddedKeyUpsertCertIntoAgent(keyToAdd, logger)
if err == nil {
return nil
}
@@ -206,7 +305,10 @@ func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte,
// barfs on timeouts missing, so we rety without a timeout in case
// we are on windows OR we have an agent running on windows thar is forwarded
// to us.
- err = sshagent.UpsertCertIntoAgent(certText, signer, filePrefix+"-"+userName, 0, logger)
+ keyToAdd.LifetimeSecs = 0
+ // confirmation is also broken on windows, but since it is an opt-in security
+ // feature we never change the user preference
+ err = sshagent.WithAddedKeyUpsertCertIntoAgent(keyToAdd, logger)
if err == nil {
return nil
}
@@ -227,104 +329,121 @@ func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte,
return ioutil.WriteFile(sshCertPath, certText, 0644)
}
+func makeDirs(homeDir string) error {
+ sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix)
+ sshConfigPath, _ := filepath.Split(sshKeyPath)
+ if err := os.MkdirAll(sshConfigPath, 0700); err != nil {
+ return err
+ }
+ tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix)
+ tlsConfigPath, _ := filepath.Split(tlsKeyPath)
+ if err := os.MkdirAll(tlsConfigPath, 0700); err != nil {
+ return err
+ }
+ return nil
+}
+
func setupCerts(
userName string,
homeDir string,
configContents config.AppConfigFile,
client *http.Client,
logger log.DebugLogger) error {
+ signers := makeSigners()
//initialize the client connection
targetURLs := strings.Split(configContents.Base.Gen_Cert_URLS, ",")
err := backgroundConnectToAnyKeymasterServer(targetURLs, client, logger)
if err != nil {
return err
}
-
- // create dirs
- sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix)
- sshConfigPath, _ := filepath.Split(sshKeyPath)
- err = os.MkdirAll(sshConfigPath, 0700)
- if err != nil {
+ if err := makeDirs(homeDir); err != nil {
return err
}
+ sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix)
tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix)
- tlsConfigPath, _ := filepath.Split(tlsKeyPath)
- err = os.MkdirAll(tlsConfigPath, 0700)
- if err != nil {
- return err
- }
- // Setup Signers
- x509Signer, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
- if err != nil {
- return err
- }
- sshRsaSigner, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
- if err != nil {
- return err
+ var baseUrl string
+ _webauthBrowser := configContents.Base.WebauthBrowser
+ if *webauthBrowser != "" {
+ _webauthBrowser = *webauthBrowser
+ }
+ if _webauthBrowser != "" {
+ // Authenticate using web browser.
+ baseUrl, err = webauth.Authenticate(userName, _webauthBrowser,
+ filepath.Join(homeDir, keymasterSubdir, FilePrefix+".webtoken"),
+ targetURLs, client, userAgentString, logger)
+ if err != nil {
+ return err
+ }
+ } else {
+ // Authenticate using password and possible 2nd factor.
+ password, err := util.GetUserCreds(userName)
+ if err != nil {
+ return err
+ }
+ baseUrl, err = twofa.AuthenticateToTargetUrls(userName, password,
+ targetURLs, false, client,
+ userAgentString, logger)
+ if err != nil {
+ return err
+
+ }
}
- _, sshEd25519Signer, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
+ if err := signers.Wait(); err != nil {
return err
}
- // Get user creds
- password, err := util.GetUserCreds(userName)
+ x509Cert, err := twofa.DoCertRequest(signers.X509Rsa, client, userName,
+ baseUrl, "x509", configContents.Base.AddGroups, userAgentString, logger)
if err != nil {
return err
}
-
- baseUrl, err := twofa.AuthenticateToTargetUrls(userName, password,
- targetURLs, false, client,
+ kubernetesCert, err := twofa.DoCertRequest(signers.X509Rsa, client,
+ userName, baseUrl, "x509-kubernetes", configContents.Base.AddGroups,
userAgentString, logger)
- if err != nil {
- return err
-
- }
- x509Cert, err := twofa.DoCertRequest(x509Signer, client, userName, baseUrl, "x509",
- configContents.Base.AddGroups, userAgentString, logger)
- if err != nil {
- return err
- }
- kubernetesCert, err := twofa.DoCertRequest(x509Signer, client, userName, baseUrl, "x509-kubernetes",
- configContents.Base.AddGroups, userAgentString, logger)
if err != nil {
logger.Debugf(0, "kubernetes cert not available")
}
- sshRsaCert, err := twofa.DoCertRequest(sshRsaSigner, client, userName, baseUrl, "ssh",
- configContents.Base.AddGroups, userAgentString, logger)
+ sshRsaCert, err := twofa.DoCertRequest(signers.SshRsa, client, userName,
+ baseUrl, "ssh", configContents.Base.AddGroups, userAgentString, logger)
if err != nil {
return err
}
- sshEd25519Cert, err := twofa.DoCertRequest(sshEd25519Signer, client, userName, baseUrl, "ssh",
- configContents.Base.AddGroups, userAgentString, logger)
+ sshEd25519Cert, err := twofa.DoCertRequest(signers.SshEd25519, client,
+ userName, baseUrl, "ssh", configContents.Base.AddGroups,
+ userAgentString, logger)
if err != nil {
logger.Debugf(1, "Ed25519 cert not available")
sshEd25519Cert = nil
}
logger.Debugf(0, "certificates successfully generated")
+ confirmKeyUse := configContents.Base.AgentConfirmUse
// Time to write certs and keys
- err = insertSSHCertIntoAgentORWriteToFilesystem(sshRsaCert,
- sshRsaSigner,
- FilePrefix+"-rsa",
- userName,
- sshKeyPath+"-rsa",
- logger)
- if err != nil {
- return err
- }
+ // old agents do not understand sha2 certs, so we inject Ed25519 first
+ // if present
if sshEd25519Cert != nil {
err = insertSSHCertIntoAgentORWriteToFilesystem(sshEd25519Cert,
- sshEd25519Signer,
+ signers.SshEd25519,
FilePrefix+"-ed25519",
userName,
sshKeyPath+"-ed25519",
+ confirmKeyUse,
logger)
if err != nil {
return err
}
}
+ err = insertSSHCertIntoAgentORWriteToFilesystem(sshRsaCert,
+ signers.SshRsa,
+ FilePrefix+"-rsa",
+ userName,
+ sshKeyPath+"-rsa",
+ confirmKeyUse,
+ logger)
+ if err != nil {
+ return err
+ }
// Now x509
- encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(x509Signer)
+ encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(signers.X509Rsa)
if err != nil {
return err
}
@@ -385,8 +504,8 @@ func getHttpClient(rootCAs *x509.CertPool, logger log.DebugLogger) (*http.Client
}
func Usage() {
- fmt.Fprintf(
- os.Stderr, "Usage of %s (version %s):\n", os.Args[0], Version)
+ fmt.Fprintf(os.Stderr, "Usage: %s [flags...] [aws-role-cert]\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, "Version: %s\n", Version)
flag.PrintDefaults()
}
@@ -402,19 +521,17 @@ func main() {
if err != nil {
logger.Fatal(err)
}
-
if *checkDevices {
u2f.CheckU2FDevices(logger)
return
}
computeUserAgent()
-
userName, homeDir, err := getUserNameAndHomeDir(logger)
if err != nil {
logger.Fatal(err)
}
config := loadConfigFile(client, logger)
-
+ logger.Debugf(3, "loaded Config=%+v", config)
// Adjust user name
if len(config.Base.Username) > 0 {
userName = config.Base.Username
@@ -423,15 +540,17 @@ func main() {
if *cliUsername != "" {
userName = *cliUsername
}
-
if len(config.Base.FilePrefix) > 0 {
FilePrefix = config.Base.FilePrefix
}
if *cliFilePrefix != "" {
FilePrefix = *cliFilePrefix
}
-
- err = setupCerts(userName, homeDir, config, client, logger)
+ if flag.Arg(0) == "aws-role-cert" {
+ err = generateAwsRoleCert(homeDir, config, client, logger)
+ } else {
+ err = setupCerts(userName, homeDir, config, client, logger)
+ }
if err != nil {
logger.Fatal(err)
}
diff --git a/cmd/keymaster/main_test.go b/cmd/keymaster/main_test.go
index 9caae10b..df2ce501 100644
--- a/cmd/keymaster/main_test.go
+++ b/cmd/keymaster/main_test.go
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "runtime"
"testing"
"time"
@@ -240,6 +241,14 @@ func TestInsertSSHCertIntoAgentORWriteToFilesystem(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ // This test needs a running agent... and remote windows
+ // builders do NOT have this... thus we need to abort this test
+ // until we have a way to NOT timeout on missing agent in
+ // windows
+ if runtime.GOOS == "windows" {
+ return
+ }
+
/////////Now actually do the work
oldSSHSock, ok := os.LookupEnv("SSH_AUTH_SOCK")
if ok {
@@ -258,6 +267,7 @@ func TestInsertSSHCertIntoAgentORWriteToFilesystem(t *testing.T) {
"someprefix",
"username",
privateKeyPath,
+ false,
testlogger.New(t))
if err != nil {
t.Fatal(err)
diff --git a/cmd/keymaster/signers.go b/cmd/keymaster/signers.go
new file mode 100644
index 00000000..c09ba0fd
--- /dev/null
+++ b/cmd/keymaster/signers.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/rsa"
+ "sync"
+)
+
+type signers struct {
+ mutex sync.RWMutex
+ err error
+ X509Rsa *rsa.PrivateKey
+ SshRsa *rsa.PrivateKey
+ SshEd25519 ed25519.PrivateKey
+}
+
+func makeSigners() *signers {
+ s := signers{}
+ s.mutex.Lock()
+ go s.compute()
+ return &s
+}
+
+func (s *signers) compute() {
+ defer s.mutex.Unlock()
+ x509Signer, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
+ if err != nil {
+ s.err = err
+ return
+ }
+ sshRsaSigner, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
+ if err != nil {
+ s.err = err
+ return
+ }
+ _, sshEd25519Signer, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ s.err = err
+ return
+ }
+ s.X509Rsa = x509Signer
+ s.SshRsa = sshRsaSigner
+ s.SshEd25519 = sshEd25519Signer
+}
+
+// Wait must be called before accessing any of the signers.
+func (s *signers) Wait() error {
+ s.mutex.RLock()
+ defer s.mutex.RUnlock()
+ return s.err
+}
diff --git a/cmd/keymasterd/2fa_bootstrapOTP.go b/cmd/keymasterd/2fa_bootstrapOTP.go
index 22f43e67..627c77e4 100644
--- a/cmd/keymasterd/2fa_bootstrapOTP.go
+++ b/cmd/keymasterd/2fa_bootstrapOTP.go
@@ -32,12 +32,12 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
"Error parsing form")
return
}
- authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
state.logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
var inputOtpHash [sha512.Size]byte
if val, ok := r.Form["OTP"]; !ok {
state.writeFailureResponse(w, r, http.StatusBadRequest,
@@ -53,7 +53,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
}
inputOtpHash = sha512.Sum512([]byte(val[0]))
}
- profile, _, fromCache, err := state.LoadUserProfile(authUser)
+ profile, _, fromCache, err := state.LoadUserProfile(authData.Username)
if err != nil {
state.logger.Printf("error loading user profile err=%s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError,
@@ -73,7 +73,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
}
if subtle.ConstantTimeCompare(inputOtpHash[:], requiredOtpHash) != 1 {
state.logger.Debugf(0, "Invalid Bootstrap OTP value for %s\n",
- authUser)
+ authData.Username)
var tmp [sha512.Size]byte
copy(tmp[:], requiredOtpHash)
state.logger.Debugf(4, " input: \"%v\" required: \"%v\"\n",
@@ -83,13 +83,13 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
return
}
profile.BootstrapOTP = bootstrapOTPData{}
- if err := state.SaveUserProfile(authUser, profile); err != nil {
+ if err := state.SaveUserProfile(authData.Username, profile); err != nil {
state.logger.Printf("error saving profile randr=%s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
return
}
_, err = state.updateAuthCookieAuthlevel(w, r,
- currentAuthLevel|AuthTypeBootstrapOTP)
+ authData.AuthType|AuthTypeBootstrapOTP)
if err != nil {
logger.Printf("Auth Cookie NOT found ? %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError,
@@ -97,7 +97,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
return
}
// eventNotifier.PublishBootstrapOtpAuthEvent(eventmon.AuthTypeBootstrapOTP,
- // authUser)
+ // authData.Username)
// Now we send the user to the appropriate place
returnAcceptType := getPreferredAcceptType(r)
// TODO: The cert backend should depend also on per user preferences.
@@ -105,7 +105,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter,
switch returnAcceptType {
case "text/html":
loginDestination := getLoginDestination(r)
- eventNotifier.PublishWebLoginEvent(authUser)
+ eventNotifier.PublishWebLoginEvent(authData.Username)
state.logger.Debugf(0, "redirecting to: %s\n", loginDestination)
http.Redirect(w, r, loginDestination, 302)
default:
diff --git a/cmd/keymasterd/2fa_okta.go b/cmd/keymasterd/2fa_okta.go
index 1acf424f..dea30ea9 100644
--- a/cmd/keymasterd/2fa_okta.go
+++ b/cmd/keymasterd/2fa_okta.go
@@ -83,19 +83,19 @@ func (state *RuntimeState) oktaPushStartHandler(w http.ResponseWriter, r *http.R
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
return
}
- authUser, _, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
oktaAuth, ok := state.passwordChecker.(*okta.PasswordAuthenticator)
if !ok {
logger.Debugf(2, "oktaPushStartHandler: password authenticator is not okta is of type %T", oktaAuth)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Apperent Misconfiguration")
return
}
- pushResponse, err := oktaAuth.ValidateUserPush(authUser)
+ pushResponse, err := oktaAuth.ValidateUserPush(authData.Username)
if err != nil {
logger.Println(err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating OKTA push")
@@ -112,6 +112,7 @@ func (state *RuntimeState) oktaPushStartHandler(w http.ResponseWriter, r *http.R
}
func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.Request) {
+ logger.Debugf(3, "top of oktaPollCheckHandler")
if state.sendFailureToClientIfLocked(w, r) {
return
}
@@ -119,19 +120,19 @@ func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.R
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
return
}
- authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
oktaAuth, ok := state.passwordChecker.(*okta.PasswordAuthenticator)
if !ok {
logger.Println("password authenticator is not okta")
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Apperent Misconfiguration")
return
}
- pushResponse, err := oktaAuth.ValidateUserPush(authUser)
+ pushResponse, err := oktaAuth.ValidateUserPush(authData.Username)
if err != nil {
logger.Println(err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating OKTA push")
@@ -141,12 +142,14 @@ func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.R
case okta.PushResponseApproved:
// TODO: add notification on eventmond
metricLogAuthOperation(getClientType(r), proto.AuthTypeOkta2FA, true)
- _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeOkta2FA)
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|AuthTypeOkta2FA)
if err != nil {
logger.Printf("Auth Cookie NOT found ? %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating Okta token")
return
}
+ logger.Debugf(2, "oktaPollCheckHandler success")
w.WriteHeader(http.StatusOK)
return
case okta.PushResponseWaiting:
diff --git a/cmd/keymasterd/2fa_totp.go b/cmd/keymasterd/2fa_totp.go
index c0eb8fc4..a5af1b74 100644
--- a/cmd/keymasterd/2fa_totp.go
+++ b/cmd/keymasterd/2fa_totp.go
@@ -74,18 +74,18 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques
return
}
// TODO: think if we are going to allow admins to register these tokens
- authUser, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// TODO: check if TOTP is even enabled.
// TODO: check for method, we should only allow POST requests
- profile, _, fromCache, err := state.LoadUserProfile(authUser)
+ profile, _, fromCache, err := state.LoadUserProfile(authData.Username)
if err != nil {
logger.Printf("loading profile error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
@@ -100,7 +100,7 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques
key, err := totp.Generate(totp.GenerateOpts{
Issuer: state.HostIdentity,
- AccountName: authUser,
+ AccountName: authData.Username,
})
if err != nil {
logger.Printf("generating new key error: %v", err)
@@ -114,7 +114,7 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques
return
}
profile.PendingTOTPSecret = &encryptedKeys
- err = state.SaveUserProfile(authUser, profile)
+ err = state.SaveUserProfile(authData.Username, profile)
if err != nil {
logger.Printf("Saving profile error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
@@ -133,7 +133,8 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques
// We need custom CSP policy to allow embedded images
w.Header().Set("Content-Security-Policy", "default-src 'self' ;img-src 'self' data: ;style-src 'self' fonts.googleapis.com 'unsafe-inline'; font-src fonts.gstatic.com fonts.googleapis.com")
displayData := newTOTPPageTemplateData{
- AuthUsername: authUser,
+ AuthUsername: authData.Username,
+ SessionExpires: authData.expires(),
Title: "New TOTP Generation", //TODO: maybe include username?
TOTPSecret: key.Secret(),
TOTPBase64Image: template.HTML(""),
@@ -233,19 +234,21 @@ func (state *RuntimeState) validateNewTOTP(w http.ResponseWriter, r *http.Reques
const totpTokenManagementPath = "/api/v0/manageTOTPToken"
-func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *http.Request) {
+func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter,
+ r *http.Request) {
// User must be logged in
if state.sendFailureToClientIfLocked(w, r) {
return
}
- // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ // TODO(camilo_viecco1): reorder checks so that simple checks are done
+ // before checking user creds
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// TODO: ensure is a valid method (POST)
if r.Method != "POST" {
logger.Printf("Wanted Post got='%s'", r.Method)
@@ -255,7 +258,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
err = r.ParseForm()
if err != nil {
logger.Println(err)
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Error parsing form")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Error parsing form")
return
}
logger.Debugf(3, "Form: %+v", r.Form)
@@ -263,11 +267,13 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
assumedUser := r.Form.Get("username")
// Have admin rights = Must be admin + authenticated with U2F
- hasAdminRights := state.IsAdminUserAndU2F(authUser, loginLevel)
+ hasAdminRights := state.IsAdminUserAndU2F(authData.Username,
+ authData.AuthType)
// Check params
- if !hasAdminRights && assumedUser != authUser {
- logger.Printf("bad username authUser=%s requested=%s", authUser, r.Form.Get("username"))
+ if !hasAdminRights && assumedUser != authData.Username {
+ logger.Printf("bad username authUser=%s requested=%s",
+ authData.Username, r.Form.Get("username"))
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
return
}
@@ -275,7 +281,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
tokenIndex, err := strconv.ParseInt(r.Form.Get("index"), 10, 64)
if err != nil {
logger.Printf("tokenindex is not a number")
- state.writeFailureResponse(w, r, http.StatusBadRequest, "tokenindex is not a number")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "tokenindex is not a number")
return
}
@@ -289,7 +296,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
}
if fromCache {
logger.Printf("DB is being cached and requesting registration aborting it")
- http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable)
+ http.Error(w, "db backend is offline for writes",
+ http.StatusServiceUnavailable)
return
}
@@ -297,7 +305,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
_, ok := profile.TOTPAuthData[tokenIndex]
if !ok {
logger.Printf("bad index number")
- state.writeFailureResponse(w, r, http.StatusBadRequest, "bad index Value")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "bad index Value")
return
}
actionName := r.Form.Get("action")
@@ -306,7 +315,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
tokenName := r.Form.Get("name")
if m, _ := regexp.MatchString("^[-/.a-zA-Z0-9_ ]+$", tokenName); !m {
logger.Printf("%s", tokenName)
- state.writeFailureResponse(w, r, http.StatusBadRequest, "invalidtokenName")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "invalidtokenName")
return
}
profile.TOTPAuthData[tokenIndex].Name = tokenName
@@ -317,7 +327,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
case "Delete":
delete(profile.TOTPAuthData, tokenIndex)
default:
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Operation")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Invalid Operation")
return
}
err = state.SaveUserProfile(assumedUser, profile)
@@ -331,7 +342,7 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt
returnAcceptType := getPreferredAcceptType(r)
switch returnAcceptType {
case "text/html":
- http.Redirect(w, r, profileURI(authUser, assumedUser), 302)
+ http.Redirect(w, r, profileURI(authData.Username, assumedUser), 302)
default:
w.WriteHeader(200)
fmt.Fprintf(w, "Success!")
@@ -442,13 +453,13 @@ func (state *RuntimeState) commonTOTPPostHandler(w http.ResponseWriter, r *http.
return "", 0, 0, errors.New("server still sealed")
}
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, requiredAuthLevel)
+ authData, err := state.checkAuth(w, r, requiredAuthLevel)
if err != nil {
logger.Debugf(1, "%v", err)
http.Error(w, "error", http.StatusInternalServerError)
return "", 0, 0, err
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// TODO: ensure is a valid method (POST)
if r.Method != "POST" {
logger.Printf("Wanted Post got='%s'", r.Method)
@@ -478,7 +489,7 @@ func (state *RuntimeState) commonTOTPPostHandler(w http.ResponseWriter, r *http.
return "", 0, 0, err
}
- return authUser, loginLevel, otpValue, nil
+ return authData.Username, authData.AuthType, otpValue, nil
}
const totpVerifyHandlerPath = "/api/v0/VerifyTOTP"
diff --git a/cmd/keymasterd/2fa_u2f.go b/cmd/keymasterd/2fa_u2f.go
index cd844b88..2ea26013 100644
--- a/cmd/keymasterd/2fa_u2f.go
+++ b/cmd/keymasterd/2fa_u2f.go
@@ -1,8 +1,11 @@
package main
import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
"encoding/json"
"fmt"
+ "math/big"
"net/http"
"strings"
"time"
@@ -10,15 +13,63 @@ import (
"github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
"github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto"
"github.com/Cloud-Foundations/keymaster/proto/eventmon"
+ "github.com/duo-labs/webauthn/protocol/webauthncose"
"github.com/tstranex/u2f"
)
-////////////////////////////
-func getRegistrationArray(U2fAuthData map[int64]*u2fAuthData) (regArray []u2f.Registration) {
- for _, data := range U2fAuthData {
- if data.Enabled {
- regArray = append(regArray, *data.Registration)
+func webauthnRegistrationToU2fRegistration(reg webauthAuthData) (*u2fAuthData, error) {
+ x, y := elliptic.Unmarshal(elliptic.P256(), reg.Credential.PublicKey)
+ if x == nil || y == nil {
+ logger.Debugf(0, "cannot decode not native p256 curve")
+ cosePubkey, err := webauthncose.ParsePublicKey(reg.Credential.PublicKey)
+ if err != nil {
+ return nil, fmt.Errorf("not a webcose pub key either")
}
+ logger.Debugf(0, "it is a cosePubkey of type %T ", cosePubkey)
+
+ coseECKey, ok := cosePubkey.(webauthncose.EC2PublicKeyData)
+ if !ok {
+ return nil, fmt.Errorf("not an Cose EC2PublicKeyData")
+ }
+ if webauthncose.COSEAlgorithmIdentifier(coseECKey.Algorithm) != webauthncose.AlgES256 {
+ return nil, fmt.Errorf("not a P256 curve")
+ }
+ x = big.NewInt(0).SetBytes(coseECKey.XCoord)
+ y = big.NewInt(0).SetBytes(coseECKey.YCoord)
+ logger.Debugf(2, "webauthnRegistrationToU2fRegistration: cose p256 curve found")
+ }
+ registration := u2f.Registration{
+ KeyHandle: reg.Credential.ID,
+ PubKey: ecdsa.PublicKey{
+ Curve: elliptic.P256(),
+ X: x,
+ Y: y,
+ },
+ }
+ authData := u2fAuthData{
+ Registration: ®istration,
+ Counter: reg.Credential.Authenticator.SignCount,
+ }
+ return &authData, nil
+}
+
+func (u *userProfile) getRegistrationArray() (regArray []u2f.Registration) {
+ for _, data := range u.U2fAuthData {
+ if !data.Enabled {
+ continue
+ }
+ regArray = append(regArray, *data.Registration)
+ }
+ for _, webauth := range u.WebauthnData {
+ if !webauth.Enabled {
+ continue
+ }
+ u2fData, err := webauthnRegistrationToU2fRegistration(*webauth)
+ if err != nil {
+ logger.Debugf(3, " getRegistrationArray could not transform webauth err:%s", err)
+ continue
+ }
+ regArray = append(regArray, *u2fData.Registration)
}
return regArray
}
@@ -42,20 +93,17 @@ func (state *RuntimeState) u2fRegisterRequest(w http.ResponseWriter, r *http.Req
return
}
- /*
-
- /*
- */
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// Check that they can change other users
- if !state.IsAdminUserAndU2F(authUser, loginLevel) && authUser != assumedUser {
+ if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) &&
+ authData.Username != assumedUser {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@@ -80,7 +128,7 @@ func (state *RuntimeState) u2fRegisterRequest(w http.ResponseWriter, r *http.Req
return
}
profile.RegistrationChallenge = c
- registrations := getRegistrationArray(profile.U2fAuthData)
+ registrations := profile.getRegistrationArray()
req := u2f.NewWebRegisterRequest(c, registrations)
logger.Printf("registerRequest: %+v", req)
@@ -115,15 +163,16 @@ func (state *RuntimeState) u2fRegisterResponse(w http.ResponseWriter, r *http.Re
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// Check that they can change other users
- if !state.IsAdminUserAndU2F(authUser, loginLevel) && authUser != assumedUser {
+ if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) &&
+ authData.Username != assumedUser {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@@ -167,8 +216,8 @@ func (state *RuntimeState) u2fRegisterResponse(w http.ResponseWriter, r *http.Re
CreatedAt: time.Now(),
CreatorAddr: r.RemoteAddr,
}
- if authUser != assumedUser {
- newReg.Name = fmt.Sprintf("Registered by %s", authUser)
+ if authData.Username != assumedUser {
+ newReg.Name = fmt.Sprintf("Registered by %s", authData.Username)
}
newIndex := newReg.CreatedAt.Unix()
profile.U2fAuthData[newIndex] = &newReg
@@ -194,15 +243,15 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, _, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
//////////
- profile, ok, _, err := state.LoadUserProfile(authUser)
+ profile, ok, _, err := state.LoadUserProfile(authData.Username)
if err != nil {
logger.Printf("loading profile error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
@@ -211,10 +260,10 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request
/////////
if !ok {
- http.Error(w, "No regstered data", http.StatusBadRequest)
+ http.Error(w, "No registered data", http.StatusBadRequest)
return
}
- registrations := getRegistrationArray(profile.U2fAuthData)
+ registrations := profile.getRegistrationArray()
if len(registrations) < 1 {
http.Error(w, "registration missing", http.StatusBadRequest)
return
@@ -232,7 +281,7 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request
localAuth.U2fAuthChallenge = c
localAuth.ExpiresAt = time.Now().Add(maxAgeU2FVerifySeconds * time.Second)
state.Mutex.Lock()
- state.localAuthData[authUser] = localAuth
+ state.localAuthData[authData.Username] = localAuth
state.Mutex.Unlock()
req := c.SignRequest(registrations)
@@ -255,12 +304,12 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
//now the actual work
var signResp u2f.SignResponse
@@ -271,7 +320,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
logger.Debugf(1, "signResponse: %+v", signResp)
- profile, ok, _, err := state.LoadUserProfile(authUser)
+ profile, ok, _, err := state.LoadUserProfile(authData.Username)
if err != nil {
logger.Printf("loading profile error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
@@ -284,7 +333,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
http.Error(w, "No regstered data", http.StatusBadRequest)
return
}
- registrations := getRegistrationArray(profile.U2fAuthData)
+ registrations := profile.getRegistrationArray()
if len(registrations) < 1 {
http.Error(w, "registration missing", http.StatusBadRequest)
return
@@ -295,7 +344,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
return
}
state.Mutex.Lock()
- localAuth, ok := state.localAuthData[authUser]
+ localAuth, ok := state.localAuthData[authData.Username]
state.Mutex.Unlock()
if !ok {
http.Error(w, "challenge missing", http.StatusBadRequest)
@@ -304,7 +353,9 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
//var err error
for i, u2fReg := range profile.U2fAuthData {
- //newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *profile.U2fAuthChallenge, u2fReg.Counter)
+ if !u2fReg.Enabled {
+ continue
+ }
newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *localAuth.U2fAuthChallenge, u2fReg.Counter)
if authErr == nil {
metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true)
@@ -316,14 +367,15 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
u2fReg.Counter = newCounter
profile.U2fAuthData[i] = u2fReg
//profile.U2fAuthChallenge = nil
- delete(state.localAuthData, authUser)
+ delete(state.localAuthData, authData.Username)
- eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authUser)
+ eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username)
_, isXHR := r.Header["X-Requested-With"]
if isXHR {
- eventNotifier.PublishWebLoginEvent(authUser)
+ eventNotifier.PublishWebLoginEvent(authData.Username)
}
- _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeU2F)
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|AuthTypeU2F)
if err != nil {
logger.Printf("Auth Cookie NOT found ? %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token")
@@ -335,6 +387,37 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques
return
}
}
+ // test1: transform webahtn registration into u2f one
+ for _, webauthnData := range profile.WebauthnData {
+ if !webauthnData.Enabled {
+ continue
+ }
+ u2fReg, err := webauthnRegistrationToU2fRegistration(*webauthnData)
+ if err != nil {
+ logger.Debugf(2, "cannot transform, err:%s", err)
+ continue
+ }
+ newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *localAuth.U2fAuthChallenge, u2fReg.Counter)
+ if authErr == nil {
+ metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true)
+ logger.Debugf(0, "newCounter: %d", newCounter)
+ eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username)
+ _, isXHR := r.Header["X-Requested-With"]
+ if isXHR {
+ eventNotifier.PublishWebLoginEvent(authData.Username)
+ }
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|AuthTypeU2F)
+ if err != nil {
+ logger.Printf("Auth Cookie NOT found ? %s", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token")
+ return
+ }
+ // TODO: update local cookie state
+ w.Write([]byte("success"))
+ return
+ }
+ }
metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, false)
logger.Printf("VerifySignResponse error: %v", err)
diff --git a/cmd/keymasterd/2fa_vip.go b/cmd/keymasterd/2fa_vip.go
index 72cdcca3..fad3659b 100644
--- a/cmd/keymasterd/2fa_vip.go
+++ b/cmd/keymasterd/2fa_vip.go
@@ -57,12 +57,12 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request
return
}
//authUser, authType, err := state.checkAuth(w, r, AuthTypeAny)
- authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
var OTPString string
if val, ok := r.Form["OTP"]; ok {
@@ -86,7 +86,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request
}
start := time.Now()
- valid, err := state.Config.SymantecVIP.Client.ValidateUserOTP(authUser, otpValue)
+ valid, err := state.Config.SymantecVIP.Client.ValidateUserOTP(authData.Username, otpValue)
if err != nil {
logger.Println(err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token")
@@ -98,7 +98,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request
//
metricLogAuthOperation(getClientType(r), proto.AuthTypeSymantecVIP, valid)
if !valid {
- logger.Printf("Invalid VIP OTP value login for %s", authUser)
+ logger.Printf("Invalid VIP OTP value login for %s", authData.Username)
// TODO if client is html then do a redirect back to vipLoginPage
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
return
@@ -106,9 +106,10 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request
}
// OTP check was successful
- logger.Debugf(1, "Successful vipOTP auth for user: %s", authUser)
- eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypeOTP, authUser)
- _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeSymantecVIP)
+ logger.Debugf(1, "Successful vipOTP auth for user: %s", authData.Username)
+ eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypeOTP, authData.Username)
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|AuthTypeSymantecVIP)
if err != nil {
logger.Printf("Auth Cookie NOT found ? %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token")
@@ -123,7 +124,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request
switch returnAcceptType {
case "text/html":
loginDestination := getLoginDestination(r)
- eventNotifier.PublishWebLoginEvent(authUser)
+ eventNotifier.PublishWebLoginEvent(authData.Username)
http.Redirect(w, r, loginDestination, 302)
default:
w.WriteHeader(200)
@@ -152,13 +153,13 @@ func (state *RuntimeState) vipPushStartHandler(w http.ResponseWriter, r *http.Re
state.writeFailureResponse(w, r, http.StatusBadRequest, "")
return
}
- authUser, _, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
- logger.Debugf(0, "Vip push start authuser=%s", authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ logger.Debugf(0, "Vip push start authuser=%s", authData.Username)
vipPushCookie, err := r.Cookie(vipTransactionCookieName)
if err != nil {
logger.Printf("%v", err)
@@ -178,7 +179,7 @@ func (state *RuntimeState) vipPushStartHandler(w http.ResponseWriter, r *http.Re
state.writeFailureResponse(w, r, http.StatusPreconditionFailed, "Push already sent")
return
}
- err = state.startVIPPush(vipPushCookie.Value, authUser)
+ err = state.startVIPPush(vipPushCookie.Value, authData.Username)
if err != nil {
logger.Println(err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Cookie not setup ")
@@ -224,13 +225,13 @@ func (state *RuntimeState) VIPPollCheckHandler(w http.ResponseWriter, r *http.Re
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
return
}
- authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
- logger.Debugf(1, "VIPPollCheckHandler: authuser=%s", authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ logger.Debugf(1, "VIPPollCheckHandler: authuser=%s", authData.Username)
vipPollCookie, err := r.Cookie(vipTransactionCookieName)
if err != nil {
logger.Printf("VIPPollCheckHandler: error getting poll cookie %v", err)
@@ -259,13 +260,15 @@ func (state *RuntimeState) VIPPollCheckHandler(w http.ResponseWriter, r *http.Re
}
// VIP Push check was successful
- _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeSymantecVIP)
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|AuthTypeSymantecVIP)
if err != nil {
logger.Printf("VIPPollCheckHandler: Failure to update AuthCookie %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token")
return
}
- eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypePush, authUser)
+ eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypePush,
+ authData.Username)
// TODO make something more fancy: JSON?
w.WriteHeader(http.StatusOK)
diff --git a/cmd/keymasterd/2fa_webauthn.go b/cmd/keymasterd/2fa_webauthn.go
new file mode 100644
index 00000000..7b010432
--- /dev/null
+++ b/cmd/keymasterd/2fa_webauthn.go
@@ -0,0 +1,369 @@
+package main
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/tstranex/u2f"
+
+ "github.com/duo-labs/webauthn/protocol"
+ "github.com/duo-labs/webauthn/webauthn"
+
+ "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+ "github.com/Cloud-Foundations/keymaster/proto/eventmon"
+)
+
+// from: https://github.com/duo-labs/webauthn.io/blob/3f03b482d21476f6b9fb82b2bf1458ff61a61d41/server/response.go#L15
+func webauthnJsonResponse(w http.ResponseWriter, d interface{}, c int) {
+ dj, err := json.Marshal(d)
+ if err != nil {
+ http.Error(w, "Error creating JSON response", http.StatusInternalServerError)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(c)
+ logger.Debugf(3, "webauth json response=%s", dj)
+ fmt.Fprintf(w, "%s", dj)
+}
+
+const webAutnRegististerRequestPath = "/webauthn/RegisterRequest/"
+
+// RegisterRequest?
+func (state *RuntimeState) webauthnBeginRegistration(w http.ResponseWriter, r *http.Request) {
+ logger.Debugf(3, "top of webauthnBeginRegistration")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+
+ // /u2f/RegisterRequest/
+ // pieces[0] == "" pieces[1] = "u2f" pieces[2] == "RegisterRequest"
+ pieces := strings.Split(r.URL.Path, "/")
+
+ var assumedUser string
+ if len(pieces) >= 4 {
+ assumedUser = pieces[3]
+ } else {
+ logger.Debugf(1, "webauthnBeginRegistration: bad number of pieces")
+ http.Error(w, "error", http.StatusBadRequest)
+ return
+ }
+ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ if err != nil {
+ logger.Debugf(1, "%v", err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+
+ // Check that they can change other users
+ if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) &&
+ authData.Username != assumedUser {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ profile, _, fromCache, err := state.LoadUserProfile(assumedUser)
+ if err != nil {
+ logger.Printf("webauthnBeginRegistration: loading profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+
+ }
+ if fromCache {
+ logger.Printf("DB is being cached and requesting registration aborting it")
+ http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable)
+ return
+ }
+
+ profile.FixupCredential(assumedUser, assumedUser)
+ logger.Debugf(2, "webauthnBeginRegistration profile=%+v", profile)
+ logger.Debugf(2, "webauthnBeginRegistration: About to begin BeginRegistration")
+ options, sessionData, err := state.webAuthn.BeginRegistration(profile)
+ if err != nil {
+ state.logger.Printf("webauthnBeginRegistration: begin login failed %s", err)
+ // TODO: we should not be sending ALL the errors to clients
+ webauthnJsonResponse(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ profile.WebauthnSessionData = sessionData
+ err = state.SaveUserProfile(assumedUser, profile)
+ if err != nil {
+ logger.Printf("Saving profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+ }
+ webauthnJsonResponse(w, options, http.StatusOK)
+}
+
+const webAutnRegististerFinishPath = "/webauthn/RegisterFinish/"
+
+func (state *RuntimeState) webauthnFinishRegistration(w http.ResponseWriter, r *http.Request) {
+ logger.Debugf(3, "top of webauthnFinishRegistration")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+
+ // TODO: better pattern matching
+ // /u2f/RegisterRequest/
+ // pieces[0] == "" pieces[1] = "u2f" pieces[2] == "RegisterRequest"
+ pieces := strings.Split(r.URL.Path, "/")
+
+ var assumedUser string
+ if len(pieces) >= 4 {
+ assumedUser = pieces[3]
+ } else {
+ http.Error(w, "error", http.StatusBadRequest)
+ return
+ }
+ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ if err != nil {
+ logger.Debugf(1, "%v", err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+
+ // Check that they can change other users
+ if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) &&
+ authData.Username != assumedUser {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ profile, _, fromCache, err := state.LoadUserProfile(assumedUser)
+ if err != nil {
+ logger.Printf("loading profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+
+ }
+ if fromCache {
+ logger.Printf("DB is being cached and requesting registration aborting it")
+ http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable)
+ return
+ }
+
+ // load the session data
+ credential, err := state.webAuthn.FinishRegistration(profile, *profile.WebauthnSessionData, r)
+ if err != nil {
+ state.logger.Println(err)
+ webauthnJsonResponse(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ logger.Debugf(2, "new credential=%+v\n", *credential)
+
+ err = profile.AddWebAuthnCredential(*credential)
+ if err != nil {
+ logger.Printf("Saving adding credential error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+ }
+
+ err = state.SaveUserProfile(assumedUser, profile)
+ if err != nil {
+ logger.Printf("Saving profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+ }
+ webauthnJsonResponse(w, "Registration Success", http.StatusOK)
+}
+
+const webAuthnAuthBeginPath = "/webauthn/AuthBegin/"
+
+func (state *RuntimeState) webauthnAuthLogin(w http.ResponseWriter, r *http.Request) {
+ logger.Debugf(3, "top of webauthnAuthBegin")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+
+ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
+ if err != nil {
+ logger.Debugf(1, "%v", err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+
+ profile, _, fromCache, err := state.LoadUserProfile(authData.Username)
+ if err != nil {
+ logger.Printf("loading profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+
+ }
+ if fromCache {
+ logger.Debugf(1, "DB is being cached and requesting authentication, proceeding with cached values")
+ }
+
+ // TODO: there is an extension to ensure it is an actual secirity key... need to add this to the call.
+ extensions := protocol.AuthenticationExtensions{"appid": u2fAppID}
+ options, sessionData, err := state.webAuthn.BeginLogin(profile,
+ webauthn.WithAssertionExtensions(extensions))
+ if err != nil {
+ logger.Printf("webauthnAuthBegin: %s", err)
+ webauthnJsonResponse(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ c, err := u2f.NewChallenge(u2fAppID, u2fTrustedFacets)
+ if err != nil {
+ logger.Printf("u2f.NewChallenge error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+ }
+ c.Challenge, err = base64.RawURLEncoding.DecodeString(sessionData.Challenge)
+ if err != nil {
+ logger.Printf("webauthnAuthBegin base64 error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+ }
+
+ var localAuth localUserData
+ localAuth.U2fAuthChallenge = c
+ localAuth.WebAuthnChallenge = sessionData
+ localAuth.ExpiresAt = time.Now().Add(maxAgeU2FVerifySeconds * time.Second)
+ state.Mutex.Lock()
+ state.localAuthData[authData.Username] = localAuth
+ state.Mutex.Unlock()
+
+ webauthnJsonResponse(w, options, http.StatusOK)
+ logger.Debugf(3, "end of webauthnAuthBegin")
+}
+
+const webAuthnAuthFinishPath = "/webauthn/AuthFinish/"
+
+func (state *RuntimeState) webauthnAuthFinish(w http.ResponseWriter, r *http.Request) {
+ logger.Debugf(3, "top of webauthnAuthFinish")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
+ if err != nil {
+ logger.Debugf(1, "%v", err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ profile, ok, _, err := state.LoadUserProfile(authData.Username)
+ if err != nil {
+ logger.Printf("loading profile error: %v", err)
+ http.Error(w, "error", http.StatusInternalServerError)
+ return
+
+ }
+ if !ok {
+ http.Error(w, "No regstered data", http.StatusBadRequest)
+ return
+ }
+
+ state.Mutex.Lock()
+ localAuth, ok := state.localAuthData[authData.Username]
+ state.Mutex.Unlock()
+ if !ok {
+ http.Error(w, "challenge missing", http.StatusBadRequest)
+ return
+ }
+
+ parsedResponse, err := protocol.ParseCredentialRequestResponse(r)
+ if err != nil {
+ logger.Printf("Error parsing Response err =%s", err)
+ http.Error(w, "", http.StatusBadRequest)
+ return
+ }
+
+ userCredentials := profile.WebAuthnCredentials()
+ var loginCredential webauthn.Credential
+ var credentialFound bool
+ var credentialIndex int64
+ for _, cred := range userCredentials {
+ if cred.AttestationType != "fido-u2f" {
+ continue
+ }
+ if bytes.Equal(cred.ID, parsedResponse.RawID) {
+ loginCredential = cred
+ credentialFound = true
+ for i, u2fReg := range profile.U2fAuthData {
+ if !u2fReg.Enabled {
+ continue
+ }
+ if bytes.Equal(u2fReg.Registration.KeyHandle, parsedResponse.RawID) {
+ credentialIndex = i
+ }
+ }
+
+ break
+ }
+ credentialFound = false
+ }
+
+ verifiedAuth := authData.AuthType
+ if !credentialFound {
+ // DO STD webaautn verification
+ _, err = state.webAuthn.ValidateLogin(profile, *localAuth.WebAuthnChallenge, parsedResponse) // iFinishLogin(profile, *localAuth.WebAuthnChallenge, r)
+ if err != nil {
+ logger.Printf("webauthnAuthFinish: auth failure err=%s", err)
+ webauthnJsonResponse(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // TODO also update the profile with latest counter
+ verifiedAuth = AuthTypeFIDO2
+ } else {
+ // NOTE: somehow the extensions for grabbing the appID are failing
+ // So we "unroll" the important pieces of webAuthn.ValidateLogin here, with
+ // explicit changes for our appID
+ // Notice that if we where strict we would iterate over all the alloowed values.
+ session := *localAuth.WebAuthnChallenge
+ shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
+
+ rpID := state.webAuthn.Config.RPID
+ rpOrigin := state.webAuthn.Config.RPOrigin
+ appID := u2fAppID
+
+ // Handle steps 4 through 16
+ validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigin, appID, shouldVerifyUser, loginCredential.PublicKey)
+ if validError != nil {
+ logger.Printf("failed to verify webauthn parsedResponse")
+ state.writeFailureResponse(w, r, http.StatusUnauthorized, "Credential Not Found")
+ return
+ }
+
+ //loginCredential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter)
+ u2fReg, ok := profile.U2fAuthData[credentialIndex]
+ if ok {
+ u2fReg.Counter = parsedResponse.Response.AuthenticatorData.Counter
+ profile.U2fAuthData[credentialIndex] = u2fReg
+ go state.SaveUserProfile(authData.Username, profile)
+ }
+
+ verifiedAuth = AuthTypeU2F
+ logger.Debugf(3, "success (LOCAL)")
+ }
+ logger.Debugf(1, "webauthnAuthFinish: auth success")
+
+ // TODO: disinguish better between the two protocols or just use one
+ //metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true)
+ state.Mutex.Lock()
+ delete(state.localAuthData, authData.Username)
+ state.Mutex.Unlock()
+
+ //TODO: distinguish here u2f vs webauthn
+ eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username)
+ _, isXHR := r.Header["X-Requested-With"]
+ if isXHR {
+ eventNotifier.PublishWebLoginEvent(authData.Username)
+ }
+
+ _, err = state.updateAuthCookieAuthlevel(w, r,
+ authData.AuthType|verifiedAuth|AuthTypeU2F)
+ if err != nil {
+ logger.Printf("Auth Cookie NOT found ? %s", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token")
+ return
+ }
+ webauthnJsonResponse(w, "Login Success", http.StatusOK)
+}
diff --git a/cmd/keymasterd/adminHandlers.go b/cmd/keymasterd/adminHandlers.go
index b380703d..189b152f 100644
--- a/cmd/keymasterd/adminHandlers.go
+++ b/cmd/keymasterd/adminHandlers.go
@@ -19,28 +19,29 @@ const generateBoostrapOTPPath = "/admin/newBoostrapOTP"
const defaultBootstrapOTPDuration = 6 * time.Hour
const maximumBootstrapOTPDuration = 24 * time.Hour
-// Returns (true, "") if an error was sent, (false, adminUser) if an admin user.
+// Returns (true, nil) if an error was sent, (false, *authInfo) if an admin
+// user.
func (state *RuntimeState) sendFailureToClientIfNonAdmin(w http.ResponseWriter,
- r *http.Request) (bool, string) {
+ r *http.Request) (bool, *authInfo) {
if state.sendFailureToClientIfLocked(w, r) {
- return true, ""
+ return true, nil
}
// TODO: probably this should be just u2f and AuthTypeKeymasterX509... but
// probably we want also to allow configurability for this. Leaving
// AuthTypeKeymasterX509 as optional for now
- authUser, _, err := state.checkAuth(w, r,
+ authData, err := state.checkAuth(w, r,
state.getRequiredWebUIAuthLevel()|AuthTypeKeymasterX509)
if err != nil {
state.logger.Debugf(1, "%v", err)
- return true, ""
+ return true, nil
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
- if !state.IsAdminUser(authUser) {
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ if !state.IsAdminUser(authData.Username) {
state.writeFailureResponse(w, r, http.StatusUnauthorized,
"Not an admin user")
- return true, ""
+ return true, nil
}
- return false, authUser
+ return false, authData
}
func (state *RuntimeState) ensurePostAndGetUsername(w http.ResponseWriter,
@@ -84,23 +85,27 @@ func (state *RuntimeState) ensurePostAndGetUsername(w http.ResponseWriter,
func (state *RuntimeState) usersHandler(w http.ResponseWriter,
r *http.Request) {
state.logger.Debugf(3, "Top of usersHandler r=%+v", r)
- failure, authUser := state.sendFailureToClientIfNonAdmin(w, r)
- if failure || authUser == "" {
+ failure, authData := state.sendFailureToClientIfNonAdmin(w, r)
+ if failure || authData == nil {
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
users, _, err := state.GetUsers()
if err != nil {
state.logger.Printf("Getting users error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
- JSSources := []string{"/static/jquery-3.5.1.min.js"}
+ JSSources := []string{
+ "/static/jquery-3.6.4.min.js",
+ "/static/compiled/session.js",
+ }
displayData := usersPageTemplateData{
- AuthUsername: authUser,
- Title: "Keymaster Users",
- Users: users,
- JSSources: JSSources}
+ AuthUsername: authData.Username,
+ SessionExpires: authData.expires(),
+ Title: "Keymaster Users",
+ Users: users,
+ JSSources: JSSources}
err = state.htmlTemplate.ExecuteTemplate(w, "usersPage", displayData)
if err != nil {
state.logger.Printf("Failed to execute %v", err)
@@ -177,7 +182,7 @@ func (state *RuntimeState) deleteUserHandler(w http.ResponseWriter,
func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter,
r *http.Request) {
- failure, authUser := state.sendFailureToClientIfNonAdmin(w, r)
+ failure, authData := state.sendFailureToClientIfNonAdmin(w, r)
if failure {
return
}
@@ -244,8 +249,9 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter,
var fingerprint [4]byte
copy(fingerprint[:], bootstrapOtpHash[:4])
displayData := newBootstrapOTPPPageTemplateData{
- Title: "New Bootstrap OTP Value",
- AuthUsername: authUser,
+ Title: "New Bootstrap OTP Value",
+ AuthUsername: authData.Username,
+ SessionExpires: authData.expires(),
//JSSources []string
//ErrorMessage string
Username: username,
@@ -256,7 +262,7 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter,
displayData.BootstrapOTPValue = bootstrapOtpValue
} else {
err := state.sendBootstrapOtpEmail(bootstrapOtpHash[:],
- bootstrapOtpValue, duration, authUser, username)
+ bootstrapOtpValue, duration, authData.Username, username)
if err != nil {
state.logger.Printf("error sending email: %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError,
@@ -272,7 +278,7 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter,
}
state.logger.Debugf(0,
"%s: generated bootstrap OTP for: %s, duration: %s, hash: %x\n",
- authUser, username, duration, bootstrapOtpHash)
+ authData.Username, username, duration, bootstrapOtpHash)
returnAcceptType := getPreferredAcceptType(r)
switch returnAcceptType {
case "text/html":
diff --git a/cmd/keymasterd/adminHandlers_test.go b/cmd/keymasterd/adminHandlers_test.go
index d227f430..de502ab2 100644
--- a/cmd/keymasterd/adminHandlers_test.go
+++ b/cmd/keymasterd/adminHandlers_test.go
@@ -89,15 +89,15 @@ func TestAuthNoTLS(t *testing.T) {
recorder := httptest.NewRecorder()
w := &instrumentedwriter.LoggingWriter{ResponseWriter: recorder}
req := httptest.NewRequest("GET", usersPath, nil)
- errorSent, username := state.sendFailureToClientIfNonAdmin(w, req)
+ errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req)
if errorSent {
return
}
if recorder.Result().StatusCode != http.StatusUnauthorized {
t.Errorf("unexpected status code: %d", recorder.Result().StatusCode)
}
- if username != "" {
- t.Errorf("expected no username, got: %s", username)
+ if authData.Username != "" {
+ t.Errorf("expected no username, got: %s", authData.Username)
}
}
@@ -112,15 +112,15 @@ func TestAuthCertAdminUser(t *testing.T) {
req := httptest.NewRequest("GET", usersPath, nil)
req.TLS, err = testMakeConnectionState("testdata/alice.pem",
"testdata/KeymasterCA.pem")
- errorSent, username := state.sendFailureToClientIfNonAdmin(w, req)
+ errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req)
if errorSent {
t.Fatal("error was sent")
}
if recorder.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status code: %d", recorder.Result().StatusCode)
}
- if username != "alice" {
- t.Fatalf("unexpected username: alice, got: %s", username)
+ if authData.Username != "alice" {
+ t.Fatalf("unexpected username: alice, got: %s", authData.Username)
}
}
@@ -135,15 +135,15 @@ func TestAuthCertPlainUser(t *testing.T) {
req := httptest.NewRequest("GET", usersPath, nil)
req.TLS, err = testMakeConnectionState("testdata/bob.pem",
"testdata/KeymasterCA.pem")
- errorSent, username := state.sendFailureToClientIfNonAdmin(w, req)
+ errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req)
if !errorSent {
t.Error("no error was sent")
}
if recorder.Result().StatusCode != http.StatusUnauthorized {
t.Errorf("unexpected status code: %d", recorder.Result().StatusCode)
}
- if username != "" {
- t.Errorf("expected no username, got: %s", username)
+ if authData != nil {
+ t.Errorf("expected no authData, got: %v", authData)
}
}
@@ -158,15 +158,15 @@ func TestAuthCertFakeAdminUser(t *testing.T) {
req := httptest.NewRequest("GET", usersPath, nil)
req.TLS, err = testMakeConnectionState("testdata/alice-fake.pem",
"testdata/AdminCA.pem")
- errorSent, username := state.sendFailureToClientIfNonAdmin(w, req)
+ errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req)
if !errorSent {
t.Error("no error was sent")
}
if recorder.Result().StatusCode != http.StatusUnauthorized {
t.Errorf("unexpected status code: %d", recorder.Result().StatusCode)
}
- if username != "" {
- t.Errorf("expected no username, got: %s", username)
+ if authData != nil {
+ t.Errorf("expected no authData, got: %v", authData)
}
}
diff --git a/cmd/keymasterd/app.go b/cmd/keymasterd/app.go
index 759c7bfa..0f471eac 100644
--- a/cmd/keymasterd/app.go
+++ b/cmd/keymasterd/app.go
@@ -29,6 +29,7 @@ import (
"time"
"golang.org/x/net/context"
+ "golang.org/x/time/rate"
"github.com/Cloud-Foundations/Dominator/lib/log/serverlogger"
"github.com/Cloud-Foundations/Dominator/lib/logbuf"
@@ -41,16 +42,18 @@ import (
"github.com/Cloud-Foundations/keymaster/keymasterd/admincache"
"github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier"
"github.com/Cloud-Foundations/keymaster/lib/authenticators/okta"
- "github.com/Cloud-Foundations/keymaster/lib/authutil"
"github.com/Cloud-Foundations/keymaster/lib/certgen"
"github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+ "github.com/Cloud-Foundations/keymaster/lib/paths"
"github.com/Cloud-Foundations/keymaster/lib/pwauth"
+ "github.com/Cloud-Foundations/keymaster/lib/server/aws_identity_cert"
"github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto"
"github.com/Cloud-Foundations/keymaster/proto/eventmon"
"github.com/Cloud-Foundations/tricorder/go/healthserver"
"github.com/Cloud-Foundations/tricorder/go/tricorder"
"github.com/Cloud-Foundations/tricorder/go/tricorder/units"
"github.com/cloudflare/cfssl/revoke"
+ "github.com/duo-labs/webauthn/webauthn"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tstranex/u2f"
@@ -67,14 +70,21 @@ const (
AuthTypeOkta2FA
AuthTypeBootstrapOTP
AuthTypeKeymasterX509
+ AuthTypeWebauthForCLI
+ AuthTypeFIDO2
)
-const AuthTypeAny = 0xFFFF
+const (
+ AuthTypeAny = 0xFFFF
+ maxCacheLifetime = time.Hour
+ maxWebauthForCliTokenLifetime = time.Hour * 24 * 366
+)
type authInfo struct {
+ AuthType int
ExpiresAt time.Time
+ IssuedAt time.Time
Username string
- AuthType int
}
type authInfoJWT struct {
@@ -109,6 +119,13 @@ type u2fAuthData struct {
Registration *u2f.Registration
}
+type webauthAuthData struct {
+ Enabled bool
+ CreatedAt time.Time
+ Name string
+ Credential webauthn.Credential
+}
+
type totpAuthData struct {
Enabled bool
CreatedAt time.Time
@@ -131,17 +148,25 @@ type userProfile struct {
TOTPAuthData map[int64]*totpAuthData
BootstrapOTP bootstrapOTPData
UserHasRegistered2ndFactor bool
+
+ WebauthnData map[int64]*webauthAuthData
+ WebauthnID uint64 // maybe more specific?
+ DisplayName string
+ Username string
+ WebauthnSessionData *webauthn.SessionData
}
type localUserData struct {
- U2fAuthChallenge *u2f.Challenge
- ExpiresAt time.Time
+ U2fAuthChallenge *u2f.Challenge
+ WebAuthnChallenge *webauthn.SessionData
+ ExpiresAt time.Time
}
type pendingAuth2Request struct {
- ExpiresAt time.Time
- state string
- ctx context.Context
+ ctx context.Context
+ ExpiresAt time.Time
+ loginDestination string
+ state string
}
type pushPollTransaction struct {
@@ -158,38 +183,40 @@ type totpRateLimitInfo struct {
}
type RuntimeState struct {
- Config AppConfigFile
- SSHCARawFileContent []byte
- Signer crypto.Signer
- Ed25519CAFileContent []byte
- Ed25519Signer crypto.Signer
- ClientCAPool *x509.CertPool
- HostIdentity string
- KerberosRealm *string
- caCertDer []byte
- certManager *certmanager.CertificateManager
- vipPushCookie map[string]pushPollTransaction
- localAuthData map[string]localUserData
- SignerIsReady chan bool
- oktaUsernameFilterRE *regexp.Regexp
- Mutex sync.Mutex
- gitDB *gitdb.UserInfo
- pendingOauth2 map[string]pendingAuth2Request
- storageRWMutex sync.RWMutex
- db *sql.DB
- dbType string
- cacheDB *sql.DB
- remoteDBQueryTimeout time.Duration
- htmlTemplate *htmltemplate.Template
- passwordChecker pwauth.PasswordAuthenticator
- KeymasterPublicKeys []crypto.PublicKey
- isAdminCache *admincache.Cache
- emailManager configuredemail.EmailManager
- textTemplates *texttemplate.Template
-
- totpLocalRateLimit map[string]totpRateLimitInfo
- totpLocalTateLimitMutex sync.Mutex
- logger log.DebugLogger
+ Config AppConfigFile
+ SSHCARawFileContent []byte
+ Signer crypto.Signer
+ Ed25519CAFileContent []byte
+ Ed25519Signer crypto.Signer
+ ClientCAPool *x509.CertPool
+ HostIdentity string
+ KerberosRealm *string
+ caCertDer []byte
+ certManager *certmanager.CertificateManager
+ vipPushCookie map[string]pushPollTransaction
+ localAuthData map[string]localUserData
+ SignerIsReady chan bool
+ oktaUsernameFilterRE *regexp.Regexp
+ passwordAttemptGlobalLimiter *rate.Limiter
+ Mutex sync.Mutex
+ gitDB *gitdb.UserInfo
+ pendingOauth2 map[string]pendingAuth2Request
+ storageRWMutex sync.RWMutex
+ db *sql.DB
+ dbType string
+ cacheDB *sql.DB
+ remoteDBQueryTimeout time.Duration
+ htmlTemplate *htmltemplate.Template
+ passwordChecker pwauth.PasswordAuthenticator
+ KeymasterPublicKeys []crypto.PublicKey
+ isAdminCache *admincache.Cache
+ emailManager configuredemail.EmailManager
+ textTemplates *texttemplate.Template
+ awsCertIssuer *aws_identity_cert.Issuer
+ webAuthn *webauthn.WebAuthn
+ totpLocalRateLimit map[string]totpRateLimitInfo
+ totpLocalTateLimitMutex sync.Mutex
+ logger log.DebugLogger
}
const redirectPath = "/auth/oauth2/callback"
@@ -220,6 +247,13 @@ var (
},
[]string{"client_type", "type", "result"},
)
+ passwordRateLimitExceededCounter = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "keymaster_password_rate_limit_exceeded_counter",
+ Help: "keymaster_password_rate_limit_exceeded_counter",
+ },
+ []string{"username"},
+ )
externalServiceDurationTotal = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
@@ -247,6 +281,16 @@ var (
eventNotifier *eventnotifier.EventNotifier
)
+func cacheControlHandler(h http.Handler) http.Handler {
+ maxAgeSeconds := maxCacheLifetime / time.Second
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Cache-Control",
+ fmt.Sprintf("max-age=%d, public, must-revalidate, proxy-revalidate",
+ maxAgeSeconds))
+ h.ServeHTTP(w, r)
+ })
+}
+
func metricLogAuthOperation(clientType string, authType string, success bool) {
validStr := strconv.FormatBool(success)
metricsMutex.Lock()
@@ -376,20 +420,6 @@ func checkUserPassword(username string, password string, config AppConfigFile,
metricLogAuthOperation(clientType, "password", valid)
return valid, nil
}
- if config.Base.HtpasswdFilename != "" {
- logger.Debugf(3, "I have htpasswed filename")
- buffer, err := ioutil.ReadFile(config.Base.HtpasswdFilename)
- if err != nil {
- return false, err
- }
- valid, err := authutil.CheckHtpasswdUserPassword(username, password,
- buffer)
- if err != nil {
- return false, err
- }
- metricLogAuthOperation(clientType, "password", valid)
- return valid, nil
- }
metricLogAuthOperation(clientType, "password", false)
return false, nil
}
@@ -416,15 +446,10 @@ func browserSupportsU2F(r *http.Request) bool {
if strings.Contains(r.UserAgent(), "Presto/") {
return true
}
- // Once FF support reaches main we can remove these silly checks.
- if strings.Contains(r.UserAgent(), "Firefox/57") ||
- strings.Contains(r.UserAgent(), "Firefox/58") ||
- strings.Contains(r.UserAgent(), "Firefox/59") ||
- strings.Contains(r.UserAgent(), "Firefox/6") ||
- strings.Contains(r.UserAgent(), "Firefox/7") ||
- strings.Contains(r.UserAgent(), "Firefox/8") {
+ if strings.Contains(r.UserAgent(), "Firefox/") {
return true
}
+ logger.Debugf(3, "browser doest NOT support u2f")
return false
}
@@ -447,10 +472,66 @@ func getClientType(r *http.Request) string {
}
}
+// getOriginOrReferrer will return the value of the "Origin" header, or if
+// empty, the Referrer (misspelled as "Referer" in the HTML standards).
+func getOriginOrReferrer(r *http.Request) string {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ return origin
+ }
+ return r.Referer()
+}
+
+// getUser will return the "user" value from the request form. If the username
+// contains invalid characters, the empty string is returned.
+func getUserFromRequest(r *http.Request) string {
+ user := r.Form.Get("user")
+ if user == "" {
+ return ""
+ }
+ if m, _ := regexp.MatchString("^[-.a-zA-Z0-9_+]+$", user); !m {
+ return ""
+ }
+ return user
+}
+
+func (ai *authInfo) expires() int64 {
+ if ai.ExpiresAt.IsZero() {
+ return 0
+ }
+ return ai.ExpiresAt.Unix()
+}
+
+func ensureHTMLSafeLoginDestination(loginDestination string) string {
+ if loginDestination == "" {
+ return profilePath
+ }
+ parsedLoginDestination, err := url.Parse(loginDestination)
+ if err != nil {
+ return profilePath
+ }
+ return parsedLoginDestination.String()
+
+}
+
+// checkPasswordAttemptLimit will check if the limit on password attempts has
+// been reached. If the limit has been reached, an error response is written to
+// w and an error message is returned.
+func (state *RuntimeState) checkPasswordAttemptLimit(w http.ResponseWriter,
+ r *http.Request, username string) error {
+ if !state.passwordAttemptGlobalLimiter.Allow() {
+ state.writeFailureResponse(w, r, http.StatusTooManyRequests,
+ "Too many password attempts")
+ passwordRateLimitExceededCounter.WithLabelValues(username).Inc()
+ return fmt.Errorf("too many password attempts, host: %s user: %s",
+ r.RemoteAddr, username)
+ }
+ return nil
+}
+
func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter,
r *http.Request, loginDestination string, tryShowU2f bool,
showBootstrapOTP bool) error {
- JSSources := []string{"/static/jquery-3.5.1.min.js", "/static/u2f-api.js"}
+ JSSources := []string{"/static/jquery-3.6.4.min.js", "/static/u2f-api.js"}
showU2F := browserSupportsU2F(r) && tryShowU2f
if showU2F {
JSSources = append(JSSources, "/static/webui-2fa-u2f.js")
@@ -461,15 +542,17 @@ func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter,
if state.Config.Okta.Enable2FA {
JSSources = append(JSSources, "/static/webui-2fa-okta-push.js")
}
+ safeLoginDestination := ensureHTMLSafeLoginDestination(loginDestination)
displayData := secondFactorAuthTemplateData{
- Title: "Keymaster 2FA Auth",
- JSSources: JSSources,
- ShowBootstrapOTP: showBootstrapOTP,
- ShowVIP: state.Config.SymantecVIP.Enabled,
- ShowU2F: showU2F,
- ShowTOTP: state.Config.Base.EnableLocalTOTP,
- ShowOktaOTP: state.Config.Okta.Enable2FA,
- LoginDestination: loginDestination}
+ Title: "Keymaster 2FA Auth",
+ JSSources: JSSources,
+ ShowBootstrapOTP: showBootstrapOTP,
+ ShowVIP: state.Config.SymantecVIP.Enabled,
+ ShowU2F: showU2F,
+ ShowTOTP: state.Config.Base.EnableLocalTOTP,
+ ShowOktaOTP: state.Config.Okta.Enable2FA,
+ LoginDestinationInput: htmltemplate.HTML(""),
+ }
err := state.htmlTemplate.ExecuteTemplate(w, "secondFactorLoginPage",
displayData)
if err != nil {
@@ -480,22 +563,31 @@ func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter,
return nil
}
-func (state *RuntimeState) writeHTMLLoginPage(w http.ResponseWriter, r *http.Request,
- loginDestination string, errorMessage string) error {
- //footerText := state.getFooterText()
+func (state *RuntimeState) writeHTMLLoginPage(w http.ResponseWriter,
+ r *http.Request, statusCode int,
+ defaultUsername, loginDestination, errorMessage string) {
+ showBasicAuth := true
+ if state.Config.Oauth2.Enabled &&
+ (state.Config.Oauth2.ForceRedirect || state.passwordChecker == nil) {
+ showBasicAuth = false
+ }
+ w.WriteHeader(statusCode)
+
+ safeLoginDestination := ensureHTMLSafeLoginDestination(loginDestination)
displayData := loginPageTemplateData{
- Title: "Keymaster Login",
- ShowOauth2: state.Config.Oauth2.Enabled,
- HideStdLogin: state.Config.Base.HideStandardLogin,
- LoginDestination: loginDestination,
- ErrorMessage: errorMessage}
+ Title: "Keymaster Login",
+ DefaultUsername: defaultUsername,
+ ShowBasicAuth: showBasicAuth,
+ ShowOauth2: state.Config.Oauth2.Enabled,
+ LoginDestinationInput: htmltemplate.HTML(""),
+ ErrorMessage: errorMessage,
+ }
err := state.htmlTemplate.ExecuteTemplate(w, "loginPage", displayData)
if err != nil {
logger.Printf("Failed to execute %v", err)
http.Error(w, "error", http.StatusInternalServerError)
- return err
+ return
}
- return nil
}
func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter,
@@ -517,7 +609,6 @@ func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter,
if code == http.StatusUnauthorized && returnAcceptType != "text/html" {
w.Header().Set("WWW-Authenticate", `Basic realm="User Credentials"`)
}
- w.WriteHeader(code)
switch code {
case http.StatusUnauthorized:
switch returnAcceptType {
@@ -529,41 +620,54 @@ func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter,
}
authCookie = cookie
}
- loginDestnation := profilePath
- if r.URL.Path == idpOpenIDCAuthorizationPath {
- loginDestnation = r.URL.String()
+ loginDestination := profilePath
+ switch r.URL.Path {
+ case idpOpenIDCAuthorizationPath, paths.ShowAuthToken,
+ paths.SendAuthDocument:
+ loginDestination = r.URL.String()
}
if r.Method == "POST" {
/// assume it has been parsed... otherwise why are we here?
if r.Form.Get("login_destination") != "" {
- loginDestnation = getLoginDestination(r)
+ loginDestination = getLoginDestination(r)
}
}
if authCookie == nil {
// TODO: change by a message followed by an HTTP redirection
- state.writeHTMLLoginPage(w, r, loginDestnation, message)
+ state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r),
+ loginDestination, message)
return
}
info, err := state.getAuthInfoFromAuthJWT(authCookie.Value)
if err != nil {
- logger.Debugf(3, "write failure state, error from getinfo authInfoJWT")
- state.writeHTMLLoginPage(w, r, loginDestnation, "")
+ logger.Debugf(3,
+ "write failure state, error from getinfo authInfoJWT")
+ state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r),
+ loginDestination, "")
return
}
if info.ExpiresAt.Before(time.Now()) {
- state.writeHTMLLoginPage(w, r, loginDestnation, "")
+ state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r),
+ loginDestination, "")
return
}
if (info.AuthType & AuthTypePassword) == AuthTypePassword {
- state.writeHTML2FAAuthPage(w, r, loginDestnation, true, false)
+ state.writeHTML2FAAuthPage(w, r, loginDestination, true, false)
return
}
- state.writeHTMLLoginPage(w, r, loginDestnation, message)
+ if (info.AuthType & AuthTypeFederated) == AuthTypeFederated {
+ state.writeHTML2FAAuthPage(w, r, loginDestination, true, false)
+ return
+ }
+ state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r),
+ loginDestination, message)
return
default:
+ w.WriteHeader(code)
w.Write([]byte(publicErrorText))
}
default:
+ w.WriteHeader(code)
w.Write([]byte(publicErrorText))
}
}
@@ -594,15 +698,24 @@ func (state *RuntimeState) sendFailureToClientIfLocked(w http.ResponseWriter, r
return false
}
-func (state *RuntimeState) setNewAuthCookie(w http.ResponseWriter, username string, authlevel int) (string, error) {
- cookieVal, err := state.genNewSerializedAuthJWT(username, authlevel)
+func (state *RuntimeState) setNewAuthCookie(w http.ResponseWriter,
+ username string, authlevel int) (string, error) {
+ cookieVal, err := state.genNewSerializedAuthJWT(username, authlevel,
+ maxAgeSecondsAuthCookie)
if err != nil {
logger.Println(err)
return "", err
}
- expiration := time.Now().Add(time.Duration(maxAgeSecondsAuthCookie) * time.Second)
- authCookie := http.Cookie{Name: authCookieName, Value: cookieVal, Expires: expiration, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode}
-
+ expiration := time.Now().Add(time.Duration(maxAgeSecondsAuthCookie) *
+ time.Second)
+ authCookie := http.Cookie{
+ Name: authCookieName,
+ Value: cookieVal,
+ Expires: expiration,
+ Path: "/", HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteNoneMode,
+ }
//use handler with original request.
if w != nil {
http.SetCookie(w, &authCookie)
@@ -654,7 +767,7 @@ func (state *RuntimeState) isAutomationUser(username string) (bool, error) {
return false, nil
}
-func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509.Certificate) (string, error) {
+func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509.Certificate) (string, time.Time, error) {
for _, chain := range VerifiedChains {
if len(chain) < 2 {
continue
@@ -663,42 +776,42 @@ func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509
//keymaster certs as signed directly
certSignerPKFingerprint, err := getKeyFingerprint(chain[1].PublicKey)
if err != nil {
- return "", err
+ return "", time.Time{}, err
}
for _, key := range state.KeymasterPublicKeys {
fp, err := getKeyFingerprint(key)
if err != nil {
- return "", err
+ return "", time.Time{}, err
}
if certSignerPKFingerprint == fp {
- return username, nil
+ return username, chain[0].NotBefore, nil
}
}
}
- return "", nil
+ return "", time.Time{}, nil
}
-func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Certificate, r *http.Request) (string, error, error) {
+func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Certificate, r *http.Request) (string, time.Time, error, error) {
clientName := VerifiedChains[0][0].Subject.CommonName
userCert := VerifiedChains[0][0]
validIP, err := certgen.VerifyIPRestrictedX509CertIP(userCert, r.RemoteAddr)
if err != nil {
logger.Printf("Error verifying up restricted cert: %s", err)
- return "", nil, err
+ return "", time.Time{}, nil, err
}
if !validIP {
logger.Printf("Invalid IP for cert: %s is not valid for incoming connection", r.RemoteAddr)
- return "", fmt.Errorf("Bad incoming ip addres"), nil
+ return "", time.Time{}, fmt.Errorf("Bad incoming ip addres"), nil
}
// Check if there are group restrictions on
ok, err := state.isAutomationUser(clientName)
if err != nil {
- return "", nil, fmt.Errorf("checkAuth: Error checking user permissions for automation certs : %s", err)
+ return "", time.Time{}, nil, fmt.Errorf("checkAuth: Error checking user permissions for automation certs : %s", err)
}
if !ok {
- return "", fmt.Errorf("Bad username for ip restricted cert"), nil
+ return "", time.Time{}, fmt.Errorf("Bad username for ip restricted cert"), nil
}
revoked, ok, err := revoke.VerifyCertificateError(userCert)
@@ -709,29 +822,28 @@ func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Ce
if revoked == true && ok {
logger.Printf("Cert is revoked")
//state.writeFailureResponse(w, r, http.StatusUnauthorized, "revoked Cert")
- return "", fmt.Errorf("revoked cert"), nil
+ return "", time.Time{}, fmt.Errorf("revoked cert"), nil
}
- return clientName, nil, nil
+ return clientName, time.Now(), nil, nil
}
// Inspired by http://stackoverflow.com/questions/21936332/idiomatic-way-of-requiring-http-basic-auth-in-go
-func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, requiredAuthType int) (string, int, error) {
+func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, requiredAuthType int) (*authInfo, error) {
// Check csrf
if r.Method != "GET" {
- referer := r.Referer()
+ referer := getOriginOrReferrer(r)
if len(referer) > 0 && len(r.Host) > 0 {
state.logger.Debugf(3, "ref =%s, host=%s", referer, r.Host)
refererURL, err := url.Parse(referer)
if err != nil {
- return "", AuthTypeNone, err
+ return nil, err
}
state.logger.Debugf(3, "refHost =%s, host=%s",
refererURL.Host, r.Host)
if refererURL.Host != r.Host {
state.logger.Printf("CSRF detected.... rejecting with a 400")
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
- err := errors.New("CSRF detected... rejecting")
- return "", AuthTypeNone, err
+ return nil, errors.New("CSRF detected... rejecting")
}
}
}
@@ -742,26 +854,34 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req
"looks like authtype tls keymaster or ip cert, r.tls=%+v", r.TLS)
if len(r.TLS.VerifiedChains) > 0 {
if (requiredAuthType & AuthTypeKeymasterX509) != 0 {
- tlsAuthUser, err :=
+ tlsAuthUser, notBefore, err :=
state.getUsernameIfKeymasterSigned(r.TLS.VerifiedChains)
if err == nil && tlsAuthUser != "" {
- return tlsAuthUser, AuthTypeKeymasterX509, nil
+ return &authInfo{
+ AuthType: AuthTypeKeymasterX509,
+ IssuedAt: notBefore,
+ Username: tlsAuthUser,
+ }, nil
}
}
if (requiredAuthType & AuthTypeIPCertificate) != 0 {
- clientName, userErr, err :=
+ clientName, notBefore, userErr, err :=
state.getUsernameIfIPRestricted(r.TLS.VerifiedChains, r)
if userErr != nil {
state.writeFailureResponse(w, r, http.StatusForbidden,
fmt.Sprintf("%s", userErr))
- return "", AuthTypeNone, userErr
+ return nil, userErr
}
if err != nil {
state.writeFailureResponse(w, r,
http.StatusInternalServerError, "")
- return "", AuthTypeNone, err
+ return nil, err
}
- return clientName, AuthTypeIPCertificate, nil
+ return &authInfo{
+ AuthType: AuthTypeIPCertificate,
+ IssuedAt: notBefore,
+ Username: clientName,
+ }, nil
}
}
}
@@ -777,15 +897,17 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req
if (AuthTypePassword & requiredAuthType) == 0 {
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
err := errors.New("Insufficient Auth Level passwd")
- return "", AuthTypeNone, err
+ return nil, err
}
//For now try also http basic (to be deprecated)
user, pass, ok := r.BasicAuth()
if !ok {
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
//toLoginOrBasicAuth(w, r)
- err := errors.New("check_Auth, Invalid or no auth header")
- return "", AuthTypeNone, err
+ return nil, errors.New("checkAuth, Invalid or no auth header")
+ }
+ if err := state.checkPasswordAttemptLimit(w, r, user); err != nil {
+ return nil, err
}
state.Mutex.Lock()
config := state.Config
@@ -795,15 +917,19 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req
state.passwordChecker, r)
if err != nil {
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
- return "", AuthTypeNone, err
+ return nil, err
}
if !valid {
state.writeFailureResponse(w, r, http.StatusUnauthorized,
"Invalid Username/Password")
err := errors.New("Invalid Credentials")
- return "", AuthTypeNone, err
+ return nil, err
}
- return user, AuthTypePassword, nil
+ return &authInfo{
+ AuthType: AuthTypePassword,
+ IssuedAt: time.Now(),
+ Username: user,
+ }, nil
}
//Critical section
info, err := state.getAuthInfoFromAuthJWT(authCookie.Value)
@@ -811,22 +937,22 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req
//TODO check between internal and bad cookie error
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
err := errors.New("Invalid Cookie")
- return "", AuthTypeNone, err
+ return nil, err
}
//check for expiration...
if info.ExpiresAt.Before(time.Now()) {
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
err := errors.New("Expired Cookie")
- return "", AuthTypeNone, err
+ return nil, err
}
if (info.AuthType & requiredAuthType) == 0 {
state.logger.Debugf(1, "info.AuthType: %v, requiredAuthType: %v\n",
info.AuthType, requiredAuthType)
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
err := errors.New("Insufficient Auth Level in critical cookie")
- return "", info.AuthType, err
+ return nil, err
}
- return info.Username, info.AuthType, nil
+ return &info, nil
}
func (state *RuntimeState) getRequiredWebUIAuthLevel() int {
@@ -895,10 +1021,9 @@ func (state *RuntimeState) publicPathHandler(w http.ResponseWriter, r *http.Requ
switch target {
case "loginForm":
- w.WriteHeader(200)
//fmt.Fprintf(w, "%s", loginFormText)
setSecurityHeaders(w)
- state.writeHTMLLoginPage(w, r, profilePath, "")
+ state.writeHTMLLoginPage(w, r, 200, "", profilePath, "")
return
case "x509ca":
pemCert := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: state.caCertDer}))
@@ -909,8 +1034,6 @@ func (state *RuntimeState) publicPathHandler(w http.ResponseWriter, r *http.Requ
default:
state.writeFailureResponse(w, r, http.StatusNotFound, "")
return
- //w.WriteHeader(200)
- //fmt.Fprintf(w, "OK\n")
}
}
@@ -922,11 +1045,18 @@ func (state *RuntimeState) userHasU2FTokens(username string) (bool, error) {
if !ok {
return false, nil
}
- registrations := getRegistrationArray(profile.U2fAuthData)
- if len(registrations) < 1 {
- return false, nil
+ for _, u2fRegistration := range profile.U2fAuthData {
+ if u2fRegistration.Enabled {
+ return true, nil
+ }
+
}
- return true, nil
+ for _, webauthnRegustration := range profile.WebauthnData {
+ if webauthnRegustration.Enabled {
+ return true, nil
+ }
+ }
+ return false, nil
}
@@ -934,7 +1064,7 @@ const authCookieName = "auth_cookie"
const vipTransactionCookieName = "vip_push_cookie"
const maxAgeSecondsVIPCookie = 120
const randomStringEntropyBytes = 32
-const maxAgeSecondsAuthCookie = 57600
+const maxAgeSecondsAuthCookie = 16 * 3600
func genRandomString() (string, error) {
size := randomStringEntropyBytes
@@ -951,7 +1081,7 @@ func genRandomString() (string, error) {
// // is interpreted as: use whatever protocol you think is OK
func getLoginDestination(r *http.Request) string {
loginDestination := profilePath
- if r.Form.Get("login_destination") != "" {
+ if r.FormValue("login_destination") != "" {
inboundLoginDestination := r.Form.Get("login_destination")
if strings.HasPrefix(inboundLoginDestination, "/") &&
!strings.HasPrefix(inboundLoginDestination, "//") {
@@ -989,7 +1119,7 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter,
"Error parsing form")
return
}
- logger.Debugf(2, "req =%+v", r)
+ logger.Debugf(4, "req =%+v", r)
default:
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
return
@@ -1006,6 +1136,11 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter,
return
}
username = val[0]
+ // Since we are getting username from Form we need some minimal sanitization
+ // TODO: actually whitelist the username characters
+ escapedUsername := strings.Replace(username, "\n", "", -1)
+ escapedUsername = strings.Replace(escapedUsername, "\r", "", -1)
+ username = escapedUsername
}
//var password string
if val, ok := r.Form["password"]; ok {
@@ -1022,6 +1157,10 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter,
return
}
}
+ if err := state.checkPasswordAttemptLimit(w, r, username); err != nil {
+ state.logger.Debugf(1, "%v", err)
+ return
+ }
username = state.reprocessUsername(username)
valid, err := checkUserPassword(username, password, state.Config,
state.passwordChecker, r)
@@ -1094,7 +1233,7 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter,
certBackends = append(certBackends, proto.AuthTypeOkta2FA)
}
}
- // logger.Printf("current backends=%+v", certBackends)
+ state.logger.Debugf(1, "current backends=%+v", certBackends)
if len(certBackends) == 0 {
certBackends = append(certBackends, proto.AuthTypeU2F)
}
@@ -1154,15 +1293,14 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter,
return
}
-///
const logoutPath = "/api/v0/logout"
-func (state *RuntimeState) logoutHandler(w http.ResponseWriter, r *http.Request) {
+func (state *RuntimeState) logoutHandler(w http.ResponseWriter,
+ r *http.Request) {
if state.sendFailureToClientIfLocked(w, r) {
return
}
//TODO: check for CSRF (simple way: makeit post only)
-
// We first check for cookies
var authCookie *http.Cookie
for _, cookie := range r.Cookies() {
@@ -1171,18 +1309,32 @@ func (state *RuntimeState) logoutHandler(w http.ResponseWriter, r *http.Request)
}
authCookie = cookie
}
-
+ var loginUser string
if authCookie != nil {
+ info, err := state.getAuthInfoFromAuthJWT(authCookie.Value)
+ if err == nil {
+ loginUser = info.Username
+ }
expiration := time.Unix(0, 0)
- updatedAuthCookie := http.Cookie{Name: authCookieName, Value: "", Expires: expiration, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode}
+ updatedAuthCookie := http.Cookie{
+ Name: authCookieName,
+ Value: "",
+ Expires: expiration,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteNoneMode,
+ }
http.SetCookie(w, &updatedAuthCookie)
}
//redirect to login
- http.Redirect(w, r, "/", 302)
+ if loginUser == "" {
+ http.Redirect(w, r, "/", 302)
+ } else {
+ http.Redirect(w, r, fmt.Sprintf("/?user=%s", loginUser), 302)
+ }
}
-///
-
func (state *RuntimeState) _IsAdminUser(user string) (bool, error) {
for _, adminUser := range state.Config.Base.AdminUsers {
if user == adminUser {
@@ -1261,20 +1413,20 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
readOnlyMsg := ""
if assumedUser == "" {
- assumedUser = authUser
- } else if !state.IsAdminUser(authUser) {
+ assumedUser = authData.Username
+ } else if !state.IsAdminUser(authData.Username) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
- } else if (loginLevel & AuthTypeU2F) == 0 {
+ } else if (authData.AuthType & AuthTypeU2F) == 0 {
readOnlyMsg = "Admins must U2F authenticate to change the profile of others."
}
@@ -1289,11 +1441,13 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request
if fromCache {
readOnlyMsg = "The active keymaster is running disconnected from its DB backend. All token operations execpt for Authentication cannot proceed."
}
-
- JSSources := []string{"/static/jquery-3.5.1.min.js"}
+ JSSources := []string{
+ "/static/jquery-3.6.4.min.js",
+ "/static/compiled/session.js",
+ }
showU2F := browserSupportsU2F(r)
if showU2F {
- JSSources = append(JSSources, "/static/u2f-api.js", "/static/keymaster-u2f.js")
+ JSSources = append(JSSources, "/static/u2f-api.js", "/static/keymaster-u2f.js", "/static/keymaster-webauthn.js")
}
// TODO: move deviceinfo mapping/sorting to its own function
@@ -1306,6 +1460,18 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request
Index: i}
u2fdevices = append(u2fdevices, deviceData)
}
+ // TODO: make some difference
+ // also add the webauthn devices...
+ for i, tokenInfo := range profile.WebauthnData {
+ deviceData := registeredU2FTokenDisplayInfo{
+ DeviceData: fmt.Sprintf("webauthn-%s", tokenInfo.Credential.AttestationType), // TODO: replace by some other per cred data
+ Enabled: tokenInfo.Enabled,
+ Name: tokenInfo.Name, //Display name?
+ Index: i,
+ }
+ u2fdevices = append(u2fdevices, deviceData)
+ }
+
sort.Slice(u2fdevices, func(i, j int) bool {
if u2fdevices[i].Name < u2fdevices[j].Name {
return true
@@ -1328,12 +1494,14 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request
displayData := profilePageTemplateData{
Username: assumedUser,
- AuthUsername: authUser,
+ AuthUsername: authData.Username,
+ SessionExpires: authData.expires(),
Title: "Keymaster User Profile",
ShowU2F: showU2F,
JSSources: JSSources,
ReadOnlyMsg: readOnlyMsg,
- UsersLink: state.IsAdminUser(authUser),
+ UsersLink: state.IsAdminUser(authData.Username),
+ ShowLegacyRegister: state.passwordChecker != nil,
RegisteredU2FToken: u2fdevices,
ShowTOTP: showTOTP,
RegisteredTOTPDevice: totpdevices,
@@ -1354,7 +1522,6 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request
http.Error(w, "error", http.StatusInternalServerError)
return
}
- //w.Write([]byte(indexHTML))
}
const u2fTokenManagementPath = "/api/v0/manageU2FToken"
@@ -1369,13 +1536,13 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// TODO: ensure is a valid method (POST)
err = r.ParseForm()
if err != nil {
@@ -1388,11 +1555,12 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http
assumedUser := r.Form.Get("username")
// Have admin rights = Must be admin + authenticated with U2F
- hasAdminRights := state.IsAdminUserAndU2F(authUser, loginLevel)
+ hasAdminRights := state.IsAdminUserAndU2F(authData.Username,
+ authData.AuthType)
// Check params
- if !hasAdminRights && assumedUser != authUser {
- logger.Printf("bad username authUser=%s requested=%s", authUser, r.Form.Get("username"))
+ if !hasAdminRights && assumedUser != authData.Username {
+ logger.Printf("bad username authUser=%s requested=%s", authData.Username, r.Form.Get("username"))
state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
return
}
@@ -1420,8 +1588,8 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http
// Todo: check for negative values
_, ok := profile.U2fAuthData[tokenIndex]
- if !ok {
- //if tokenIndex >= len(profile.U2fAuthData) {
+ _, ok2 := profile.WebauthnData[tokenIndex]
+ if !ok && !ok2 {
logger.Printf("bad index number")
state.writeFailureResponse(w, r, http.StatusBadRequest, "bad index Value")
return
@@ -1437,13 +1605,30 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http
state.writeFailureResponse(w, r, http.StatusBadRequest, "invalidtokenName")
return
}
- profile.U2fAuthData[tokenIndex].Name = tokenName
+ if ok {
+ profile.U2fAuthData[tokenIndex].Name = tokenName
+ } else {
+ profile.WebauthnData[tokenIndex].Name = tokenName
+ }
+
case "Disable":
- profile.U2fAuthData[tokenIndex].Enabled = false
+ if ok {
+ profile.U2fAuthData[tokenIndex].Enabled = false
+ } else {
+ profile.WebauthnData[tokenIndex].Enabled = false
+ }
case "Enable":
- profile.U2fAuthData[tokenIndex].Enabled = true
+ if ok {
+ profile.U2fAuthData[tokenIndex].Enabled = true
+ } else {
+ profile.WebauthnData[tokenIndex].Enabled = true
+ }
case "Delete":
- delete(profile.U2fAuthData, tokenIndex)
+ if ok {
+ delete(profile.U2fAuthData, tokenIndex)
+ } else {
+ delete(profile.WebauthnData, tokenIndex)
+ }
default:
state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Operation")
return
@@ -1460,7 +1645,7 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http
returnAcceptType := getPreferredAcceptType(r)
switch returnAcceptType {
case "text/html":
- http.Redirect(w, r, profileURI(authUser, assumedUser), 302)
+ http.Redirect(w, r, profileURI(authData.Username, assumedUser), 302)
default:
w.WriteHeader(200)
fmt.Fprintf(w, "Success!")
@@ -1489,8 +1674,21 @@ func (state *RuntimeState) defaultPathHandler(w http.ResponseWriter, r *http.Req
//redirect to profile
if r.URL.Path[:] == "/" {
//landing page
+ if err := r.ParseForm(); err != nil {
+ logger.Println(err)
+ errCode := http.StatusInternalServerError
+ errMessage := "Error parsing form"
+ if strings.Contains(err.Error(), "invalid") {
+ errCode = http.StatusBadRequest
+ errMessage = "invalid query"
+ }
+ state.writeFailureResponse(w, r, errCode,
+ errMessage)
+ return
+ }
if r.Method == "GET" && len(r.Cookies()) < 1 {
- state.writeHTMLLoginPage(w, r, profilePath, "")
+ state.writeHTMLLoginPage(w, r, 200, getUserFromRequest(r),
+ profilePath, "")
return
}
@@ -1524,6 +1722,7 @@ func Usage() {
func init() {
prometheus.MustRegister(certGenCounter)
prometheus.MustRegister(authOperationCounter)
+ prometheus.MustRegister(passwordRateLimitExceededCounter)
prometheus.MustRegister(externalServiceDurationTotal)
prometheus.MustRegister(certDurationHistogram)
tricorder.RegisterMetric(
@@ -1603,40 +1802,73 @@ func main() {
serviceMux.HandleFunc(addUserPath, runtimeState.addUserHandler)
serviceMux.HandleFunc(deleteUserPath, runtimeState.deleteUserHandler)
//TODO: should enable only if bootraptop is enabled
- serviceMux.HandleFunc(generateBoostrapOTPPath, runtimeState.generateBootstrapOTP)
-
- serviceMux.HandleFunc(idpOpenIDCConfigurationDocumentPath, runtimeState.idpOpenIDCDiscoveryHandler)
- serviceMux.HandleFunc(idpOpenIDCJWKSPath, runtimeState.idpOpenIDCJWKSHandler)
- serviceMux.HandleFunc(idpOpenIDCAuthorizationPath, runtimeState.idpOpenIDCAuthorizationHandler)
- serviceMux.HandleFunc(idpOpenIDCTokenPath, runtimeState.idpOpenIDCTokenHandler)
- serviceMux.HandleFunc(idpOpenIDCUserinfoPath, runtimeState.idpOpenIDCUserinfoHandler)
-
- staticFilesPath := filepath.Join(runtimeState.Config.Base.SharedDataDirectory, "static_files")
- serviceMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticFilesPath))))
- customWebResourcesPath := filepath.Join(runtimeState.Config.Base.SharedDataDirectory, "customization_data", "web_resources")
+ serviceMux.HandleFunc(generateBoostrapOTPPath,
+ runtimeState.generateBootstrapOTP)
+
+ serviceMux.HandleFunc(idpOpenIDCConfigurationDocumentPath,
+ runtimeState.idpOpenIDCDiscoveryHandler)
+ serviceMux.HandleFunc(idpOpenIDCJWKSPath,
+ runtimeState.idpOpenIDCJWKSHandler)
+ serviceMux.HandleFunc(idpOpenIDCAuthorizationPath,
+ runtimeState.idpOpenIDCAuthorizationHandler)
+ serviceMux.HandleFunc(idpOpenIDCTokenPath,
+ runtimeState.idpOpenIDCTokenHandler)
+ serviceMux.HandleFunc(idpOpenIDCUserinfoPath,
+ runtimeState.idpOpenIDCUserinfoHandler)
+
+ staticFilesPath :=
+ filepath.Join(runtimeState.Config.Base.SharedDataDirectory,
+ "static_files")
+ serviceMux.Handle("/static/", cacheControlHandler(
+ http.StripPrefix("/static/",
+ http.FileServer(http.Dir(staticFilesPath)))))
+ serviceMux.Handle("/static/compiled/", cacheControlHandler(
+ http.StripPrefix("/static/compiled/", http.FileServer(AssetFile()))))
+ customWebResourcesPath :=
+ filepath.Join(runtimeState.Config.Base.SharedDataDirectory,
+ "customization_data", "web_resources")
if _, err = os.Stat(customWebResourcesPath); err == nil {
- serviceMux.Handle("/custom_static/", http.StripPrefix("/custom_static/", http.FileServer(http.Dir(customWebResourcesPath))))
- }
- serviceMux.HandleFunc(u2fRegustisterRequestPath, runtimeState.u2fRegisterRequest)
- serviceMux.HandleFunc(u2fRegisterRequesponsePath, runtimeState.u2fRegisterResponse)
+ serviceMux.Handle("/custom_static/", cacheControlHandler(
+ http.StripPrefix("/custom_static/",
+ http.FileServer(http.Dir(customWebResourcesPath)))))
+ }
+ serviceMux.HandleFunc(u2fRegustisterRequestPath,
+ runtimeState.u2fRegisterRequest)
+ serviceMux.HandleFunc(u2fRegisterRequesponsePath,
+ runtimeState.u2fRegisterResponse)
serviceMux.HandleFunc(u2fSignRequestPath, runtimeState.u2fSignRequest)
serviceMux.HandleFunc(u2fSignResponsePath, runtimeState.u2fSignResponse)
+ serviceMux.HandleFunc(webAutnRegististerRequestPath, runtimeState.webauthnBeginRegistration)
+ serviceMux.HandleFunc(webAutnRegististerFinishPath, runtimeState.webauthnFinishRegistration)
+ serviceMux.HandleFunc(webAuthnAuthBeginPath, runtimeState.webauthnAuthLogin)
+ serviceMux.HandleFunc(webAuthnAuthFinishPath, runtimeState.webauthnAuthFinish)
+
serviceMux.HandleFunc(vipAuthPath, runtimeState.VIPAuthHandler)
- serviceMux.HandleFunc(u2fTokenManagementPath, runtimeState.u2fTokenManagerHandler)
- serviceMux.HandleFunc(oauth2LoginBeginPath, runtimeState.oauth2DoRedirectoToProviderHandler)
+ serviceMux.HandleFunc(u2fTokenManagementPath,
+ runtimeState.u2fTokenManagerHandler)
+ serviceMux.HandleFunc(oauth2LoginBeginPath,
+ runtimeState.oauth2DoRedirectoToProviderHandler)
serviceMux.HandleFunc(redirectPath, runtimeState.oauth2RedirectPathHandler)
- serviceMux.HandleFunc(clientConfHandlerPath, runtimeState.serveClientConfHandler)
+ serviceMux.HandleFunc(clientConfHandlerPath,
+ runtimeState.serveClientConfHandler)
serviceMux.HandleFunc(vipPushStartPath, runtimeState.vipPushStartHandler)
serviceMux.HandleFunc(vipPollCheckPath, runtimeState.VIPPollCheckHandler)
serviceMux.HandleFunc(totpGeneratNewPath, runtimeState.GenerateNewTOTP)
serviceMux.HandleFunc(totpValidateNewPath, runtimeState.validateNewTOTP)
- serviceMux.HandleFunc(totpTokenManagementPath, runtimeState.totpTokenManagerHandler)
+ serviceMux.HandleFunc(totpTokenManagementPath,
+ runtimeState.totpTokenManagerHandler)
serviceMux.HandleFunc(totpVerifyHandlerPath, runtimeState.verifyTOTPHandler)
serviceMux.HandleFunc(totpAuthPath, runtimeState.TOTPAuthHandler)
if runtimeState.Config.Okta.Domain != "" {
serviceMux.HandleFunc(okta2FAauthPath, runtimeState.Okta2FAuthHandler)
- serviceMux.HandleFunc(oktaPushStartPath, runtimeState.oktaPushStartHandler)
- serviceMux.HandleFunc(oktaPollCheckPath, runtimeState.oktaPollCheckHandler)
+ serviceMux.HandleFunc(oktaPushStartPath,
+ runtimeState.oktaPushStartHandler)
+ serviceMux.HandleFunc(oktaPollCheckPath,
+ runtimeState.oktaPollCheckHandler)
+ }
+ if runtimeState.checkAwsRolesEnabled() {
+ serviceMux.HandleFunc(paths.RequestAwsRoleCertificatePath,
+ runtimeState.requestAwsRoleCertificateHandler)
}
// TODO(rgooch): Condition this on whether Bootstrap OTP is configured.
// The inline calls to getRequiredWebUIAuthLevel() should be
@@ -1644,6 +1876,14 @@ func main() {
// bitfield test.
serviceMux.HandleFunc(bootstrapOtpAuthPath,
runtimeState.BootstrapOtpAuthHandler)
+ if runtimeState.Config.Base.WebauthTokenForCliLifetime > 0 {
+ serviceMux.HandleFunc(paths.SendAuthDocument,
+ runtimeState.SendAuthDocumentHandler)
+ serviceMux.HandleFunc(paths.ShowAuthToken,
+ runtimeState.ShowAuthTokenHandler)
+ serviceMux.HandleFunc(paths.VerifyAuthToken,
+ runtimeState.VerifyAuthTokenHandler)
+ }
serviceMux.HandleFunc("/", runtimeState.defaultPathHandler)
cfg := &tls.Config{
@@ -1660,7 +1900,6 @@ func main() {
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
- tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
},
}
logFilterHandler := NewLogFilterHandler(http.DefaultServeMux, publicLogs,
@@ -1676,7 +1915,7 @@ func main() {
IdleTimeout: 120 * time.Second,
}
srpc.RegisterServerTlsConfig(
- &tls.Config{ClientCAs: runtimeState.ClientCAPool},
+ &tls.Config{ClientCAs: runtimeState.ClientCAPool, MinVersion: tls.VersionTLS12},
true)
go func() {
err := adminSrv.ListenAndServeTLS("", "")
@@ -1728,7 +1967,6 @@ func main() {
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
- tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
},
}
serviceSrv := &http.Server{
diff --git a/cmd/keymasterd/authToken.go b/cmd/keymasterd/authToken.go
new file mode 100644
index 00000000..c9c3fd26
--- /dev/null
+++ b/cmd/keymasterd/authToken.go
@@ -0,0 +1,217 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+ "github.com/Cloud-Foundations/keymaster/lib/paths"
+ "gopkg.in/square/go-jose.v2"
+ "gopkg.in/square/go-jose.v2/jwt"
+)
+
+func (state *RuntimeState) generateAuthJWT(username string) (string, error) {
+ signer, err := jose.NewSigner(jose.SigningKey{
+ Algorithm: jose.RS256,
+ Key: state.Signer,
+ }, (&jose.SignerOptions{}).WithType("JWT"))
+ if err != nil {
+ return "", err
+ }
+ issuer := state.idpGetIssuer()
+ now := time.Now().Unix()
+ authToken := authInfoJWT{
+ Issuer: issuer,
+ Subject: username,
+ Audience: []string{issuer},
+ Expiration: now + int64(
+ state.Config.Base.WebauthTokenForCliLifetime/time.Second),
+ NotBefore: now,
+ IssuedAt: now,
+ TokenType: "keymaster_webauth_for_cli_identity",
+ }
+ return jwt.Signed(signer).Claims(authToken).CompactSerialize()
+}
+
+func (state *RuntimeState) SendAuthDocumentHandler(w http.ResponseWriter,
+ r *http.Request) {
+ state.logger.Debugln(1, "Entered SendAuthDocumentHandler()")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+ if r.Method != "GET" && r.Method != "POST" {
+ state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ state.logger.Println(err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "Error parsing form")
+ return
+ }
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ if err != nil {
+ state.logger.Debugln(1, err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ state.logger.Printf("%s requested authentication document export\n",
+ authData.Username)
+ // Fetch form/query data.
+ var portNumber uint64
+ var token string
+ if val, ok := r.Form["port"]; !ok {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "No CLI port number provided")
+ state.logger.Printf("SendAuthDocument without port number")
+ return
+ } else {
+ if len(val) > 1 {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Just one port number allowed")
+ state.logger.Printf("SendAuthDocument with multiple port values")
+ return
+ }
+ if portNumber, err = strconv.ParseUint(val[0], 10, 16); err != nil {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Invalid port number")
+ state.logger.Printf("SendAuthDocument with invalid port number")
+ return
+ }
+ }
+ if val, ok := r.Form["token"]; !ok {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "No token provided")
+ state.logger.Printf("SendAuthDocument without token")
+ return
+ } else {
+ if len(val) > 1 {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Just one token allowed")
+ state.logger.Printf("SendAuthDocument with multiple token values")
+ return
+ }
+ token = val[0]
+ }
+ authInfo, err := state.getAuthInfoFromJWT(token,
+ "keymaster_webauth_for_cli_identity")
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Bad token")
+ state.logger.Debugln(0, err)
+ return
+ }
+ if authInfo.Username != authData.Username {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "User mismatch")
+ state.logger.Printf(
+ "SendAuthDocumentHandler: authticated user: %s != token user: %s\n",
+ authData.Username, authInfo.Username)
+ return
+ }
+ if time.Until(authInfo.ExpiresAt) < 0 {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Token expired")
+ state.logger.Debugln(0, "token expired")
+ return
+ }
+ // Generate a new cookie to send.
+ cookie, err := state.genNewSerializedAuthJWT(authInfo.Username,
+ AuthTypeWebauthForCLI,
+ int64(time.Until(authInfo.ExpiresAt)/time.Second))
+ http.Redirect(w, r,
+ fmt.Sprintf("http://localhost:%d%s?auth_cookie=%s",
+ portNumber, paths.ReceiveAuthDocument, cookie),
+ http.StatusPermanentRedirect)
+}
+
+func (state *RuntimeState) ShowAuthTokenHandler(w http.ResponseWriter,
+ r *http.Request) {
+ state.logger.Debugf(1, "Entered GetAuthTokenHandler(). URL: %v\n", r.URL)
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+ if r.Method != "GET" && r.Method != "POST" {
+ state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ logger.Println(err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "Error parsing form")
+ return
+ }
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ if err != nil {
+ state.logger.Debugf(1, "%s", err)
+ return
+ }
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ displayData := authCodePageTemplateData{
+ Title: "Keymaster CLI Token Display",
+ AuthUsername: authData.Username,
+ SessionExpires: authData.expires(),
+ }
+ token, err := state.generateAuthJWT(authData.Username)
+ if err != nil {
+ state.logger.Println(err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "Unable to generate token")
+ return
+ }
+ state.logger.Printf("generated webauth CLI token for: %s, lifetime: %s\n",
+ authData.Username, state.Config.Base.WebauthTokenForCliLifetime)
+ displayData.Token = token
+ err = state.htmlTemplate.ExecuteTemplate(w, "authTokenPage", displayData)
+ if err != nil {
+ logger.Printf("Failed to execute: %s\n", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "Error executing template")
+ }
+}
+
+func (state *RuntimeState) VerifyAuthTokenHandler(w http.ResponseWriter,
+ r *http.Request) {
+ state.logger.Debugf(1, "Entered VerifyAuthTokenHandler(). URL: %v\n", r.URL)
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+ if r.Method != "GET" && r.Method != "POST" {
+ state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ logger.Println(err)
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Error parsing form")
+ return
+ }
+ // Fetch form/query data.
+ var token string
+ if val, ok := r.Form["token"]; !ok {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "No token provided")
+ state.logger.Printf("VerifyAuthToken without token")
+ return
+ } else {
+ if len(val) > 1 {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Just one token allowed")
+ state.logger.Printf("VerifyAuthToken with multiple token values")
+ return
+ }
+ token = val[0]
+ }
+ authInfo, err := state.getAuthInfoFromJWT(token,
+ "keymaster_webauth_for_cli_identity")
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusNotAcceptable, "Bad token")
+ state.logger.Debugln(0, err)
+ return
+ }
+ if time.Until(authInfo.ExpiresAt) < 0 {
+ state.writeFailureResponse(w, r, http.StatusGone, "Token expired")
+ state.logger.Debugln(0, "token expired")
+ return
+ }
+ w.Write([]byte("OK\n"))
+}
diff --git a/cmd/keymasterd/auth_oauth2.go b/cmd/keymasterd/auth_oauth2.go
index 1e2709a4..4706f6f6 100644
--- a/cmd/keymasterd/auth_oauth2.go
+++ b/cmd/keymasterd/auth_oauth2.go
@@ -11,52 +11,62 @@ import (
"golang.org/x/net/context"
)
-const maxAgeSecondsRedirCookie = 120
-const redirCookieName = "oauth2_redir"
-
-const oauth2LoginBeginPath = "/auth/oauth2/login"
-
-func (state *RuntimeState) oauth2DoRedirectoToProviderHandler(w http.ResponseWriter, r *http.Request) {
+const (
+ maxAgeSecondsRedirCookie = 120
+ redirCookieName = "oauth2_redir"
+ oauth2LoginBeginPath = "/auth/oauth2/login"
+)
+func (state *RuntimeState) oauth2DoRedirectoToProviderHandler(
+ w http.ResponseWriter, r *http.Request) {
if state.Config.Oauth2.Config == nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println("asking for oauth2, but it is not defined")
return
}
if !state.Config.Oauth2.Enabled {
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Oauth2 is not enabled in for this system")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Oauth2 is not enabled in for this system")
logger.Println("asking for oauth2, but it is not enabled")
return
}
cookieVal, err := genRandomString()
if err != nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println(err)
return
}
-
// we have to create new context and set redirector...
- expiration := time.Now().Add(time.Duration(maxAgeSecondsRedirCookie) * time.Second)
-
+ expiration := time.Now().Add(time.Duration(maxAgeSecondsRedirCookie) *
+ time.Second)
stateString, err := genRandomString()
if err != nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println(err)
return
}
-
- cookie := http.Cookie{Name: redirCookieName, Value: cookieVal,
- Expires: expiration, Path: "/", HttpOnly: true}
+ cookie := http.Cookie{
+ Name: redirCookieName,
+ Value: cookieVal,
+ Expires: expiration,
+ Path: "/",
+ HttpOnly: true,
+ }
http.SetCookie(w, &cookie)
-
pending := pendingAuth2Request{
- ExpiresAt: expiration,
- state: stateString,
- ctx: context.Background()}
+ ctx: context.Background(),
+ ExpiresAt: expiration,
+ loginDestination: getLoginDestination(r),
+ state: stateString,
+ }
state.Mutex.Lock()
state.pendingOauth2[cookieVal] = pending
state.Mutex.Unlock()
- http.Redirect(w, r, state.Config.Oauth2.Config.AuthCodeURL(stateString), http.StatusFound)
+ http.Redirect(w, r, state.Config.Oauth2.Config.AuthCodeURL(stateString),
+ http.StatusFound)
}
func httpGet(client *http.Client, url string) ([]byte, error) {
@@ -64,45 +74,43 @@ func httpGet(client *http.Client, url string) ([]byte, error) {
if err != nil {
return nil, err
}
-
defer r.Body.Close()
-
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, err
}
-
if r.StatusCode >= 300 {
return nil, fmt.Errorf(string(body))
}
-
logger.Debugf(8, "HTTP GET %s: %s %s", url, r.Status, string(body))
-
return body, nil
}
-func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *http.Request) {
-
+func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter,
+ r *http.Request) {
if state.Config.Oauth2.Config == nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println("asking for oauth2, but it is not defined")
return
}
if !state.Config.Oauth2.Enabled {
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Oauth2 is not enabled in for this system")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Oauth2 is not enabled in for this system")
logger.Println("asking for oauth2, but it is not enabled")
return
}
-
redirCookie, err := r.Cookie(redirCookieName)
if err != nil {
if err == http.ErrNoCookie {
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Missing setup cookie!")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Missing setup cookie!")
logger.Println(err)
return
}
// TODO: this is probably a user error? send back to oath2 login path?
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println(err)
return
}
@@ -112,34 +120,33 @@ func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *h
state.Mutex.Unlock()
if !ok {
// clear cookie here!!!!
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid setup cookie!")
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Invalid setup cookie!")
logger.Println(err)
return
}
-
if r.URL.Query().Get("state") != pending.state {
logger.Printf("state does not match")
http.Error(w, "state did not match", http.StatusBadRequest)
return
}
- //if Debug {
- //logger.Printf("req : %+v", r)
- //}
- oauth2Token, err := state.Config.Oauth2.Config.Exchange(pending.ctx, r.URL.Query().Get("code"))
+ oauth2Token, err := state.Config.Oauth2.Config.Exchange(pending.ctx,
+ r.URL.Query().Get("code"))
if err != nil {
logger.Printf("failed to get token: ctx: %+v", pending.ctx)
- http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Failed to exchange token: "+err.Error(),
+ http.StatusInternalServerError)
return
}
client := state.Config.Oauth2.Config.Client(pending.ctx, oauth2Token)
- //client.Get("...")
body, err := httpGet(client, state.Config.Oauth2.UserinfoUrl)
if err != nil {
- logger.Printf("fail to fetch %s (%s) ", state.Config.Oauth2.UserinfoUrl, err.Error())
- http.Error(w, "Failed to get userinfo from url: "+err.Error(), http.StatusInternalServerError)
+ logger.Printf("fail to fetch %s (%s) ", state.Config.Oauth2.UserinfoUrl,
+ err.Error())
+ http.Error(w, "Failed to get userinfo from url: "+err.Error(),
+ http.StatusInternalServerError)
return
}
-
var data struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -148,46 +155,46 @@ func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *h
Email string `json:"email"`
Attributes map[string][]string `json:"attributes"`
}
-
logger.Debugf(3, "Userinfo body:'%s'", string(body))
err = json.Unmarshal(body, &data)
if err != nil {
logger.Printf("failed to unmarshall userinfo to fetch %s ", body)
- http.Error(w, "Failed to get unmarshall userinfo: "+err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Failed to get unmarshall userinfo: "+err.Error(),
+ http.StatusInternalServerError)
return
}
-
// The Name field could also be useful
logger.Debugf(2, "%+v", data)
-
// Check if name is there..
-
- // TODO: we need a more robust way to get the username and to add some filters. This
- // mechanism is ok for 0.2 but not for 0.3.
+ // TODO: we need a more robust way to get the username and to add some
+ // filters. This mechanism is ok for 0.2 but not for 0.3.
username := data.Login
if username == "" {
components := strings.Split(data.Email, "@")
if len(components[0]) < 1 {
- http.Error(w, "Email from userinfo is invalid: ", http.StatusInternalServerError)
+ http.Error(w, "Email from userinfo is invalid: ",
+ http.StatusInternalServerError)
return
}
username = strings.ToLower(components[0])
}
-
- //Make new auth cookie
+ // Make new auth cookie
_, err = state.setNewAuthCookie(w, username, AuthTypeFederated)
if err != nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal")
+ state.writeFailureResponse(w, r, http.StatusInternalServerError,
+ "error internal")
logger.Println(err)
return
}
-
- // delete peding cookie
+ // Delete pending cookie
state.Mutex.Lock()
delete(state.pendingOauth2, index)
state.Mutex.Unlock()
-
eventNotifier.PublishWebLoginEvent(username)
- //and redirect to profile page
- http.Redirect(w, r, profilePath, 302)
+ loginDestination := pending.loginDestination
+ if loginDestination == "" {
+ // Nowhere else to go: go to profile page.
+ loginDestination = profilePath
+ }
+ http.Redirect(w, r, loginDestination, 302)
}
diff --git a/cmd/keymasterd/awsRole.go b/cmd/keymasterd/awsRole.go
new file mode 100644
index 00000000..1066cb22
--- /dev/null
+++ b/cmd/keymasterd/awsRole.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/x509"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/organizations"
+ "github.com/aws/aws-sdk-go-v2/service/sts"
+
+ "github.com/Cloud-Foundations/keymaster/lib/certgen"
+ "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+)
+
+const (
+ awsAccountListInterval = time.Minute * 5
+)
+
+type assumeRoleCredentialsProvider struct {
+ credentials aws.Credentials
+ roleArn *string
+ stsClient *sts.Client
+}
+
+func awsListAccounts(ctx context.Context, orgClient *organizations.Client) (
+ map[string]struct{}, error) {
+ list := make(map[string]struct{})
+ var nextToken *string
+ for {
+ output, err := orgClient.ListAccounts(ctx,
+ &organizations.ListAccountsInput{NextToken: nextToken})
+ if err != nil {
+ return nil, err
+ }
+ for _, account := range output.Accounts {
+ list[*account.Id] = struct{}{}
+ }
+ if output.NextToken == nil {
+ break
+ }
+ nextToken = output.NextToken
+ }
+ return list, nil
+}
+
+func (p *assumeRoleCredentialsProvider) Retrieve(ctx context.Context) (
+ aws.Credentials, error) {
+ if time.Until(p.credentials.Expires) > time.Minute {
+ return p.credentials, nil
+ }
+ output, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
+ RoleArn: p.roleArn,
+ RoleSessionName: aws.String("keymaster"),
+ })
+ if err != nil {
+ return aws.Credentials{}, err
+ }
+ p.credentials = aws.Credentials{
+ AccessKeyID: *output.Credentials.AccessKeyId,
+ CanExpire: true,
+ Expires: *output.Credentials.Expiration,
+ SecretAccessKey: *output.Credentials.SecretAccessKey,
+ SessionToken: *output.Credentials.SessionToken,
+ }
+ return p.credentials, nil
+}
+
+func (state *RuntimeState) checkAwsRolesEnabled() bool {
+ if len(state.Config.AwsCerts.AllowedAccounts) > 0 {
+ return true
+ }
+ if state.Config.AwsCerts.ListAccountsRole != "" {
+ return true
+ }
+ return false
+}
+
+func (state *RuntimeState) configureAwsRoles() error {
+ if len(state.Config.AwsCerts.AllowedAccounts) > 0 {
+ state.Config.AwsCerts.allowedAccounts =
+ make(map[string]struct{})
+ for _, id := range state.Config.AwsCerts.AllowedAccounts {
+ if id != "*" {
+ if _, err := strconv.ParseUint(id, 10, 64); err != nil {
+ return fmt.Errorf("accountID: %s is not a number", id)
+ }
+ }
+ state.Config.AwsCerts.allowedAccounts[id] = struct{}{}
+ }
+ }
+ if state.Config.AwsCerts.ListAccountsRole != "" {
+ ctx := context.TODO()
+ awsConfig, err := awsconfig.LoadDefaultConfig(ctx,
+ awsconfig.WithEC2IMDSRegion())
+ if err != nil {
+ return err
+ }
+ credsProvider := &assumeRoleCredentialsProvider{
+ roleArn: aws.String(state.Config.AwsCerts.ListAccountsRole),
+ stsClient: sts.NewFromConfig(awsConfig),
+ }
+ awsConfig, err = awsconfig.LoadDefaultConfig(ctx,
+ awsconfig.WithEC2IMDSRegion(),
+ awsconfig.WithCredentialsProvider(credsProvider))
+ if err != nil {
+ return err
+ }
+ orgClient := organizations.NewFromConfig(awsConfig)
+ state.Config.AwsCerts.organisationAccounts, err =
+ awsListAccounts(ctx, orgClient)
+ if err != nil {
+ return err
+ }
+ state.logger.Printf("Discovered %d accounts in AWS Organisation\n",
+ len(state.Config.AwsCerts.organisationAccounts))
+ go state.refreshAwsAccounts(ctx, orgClient)
+ }
+ return nil
+}
+
+func (state *RuntimeState) checkAwsAccountAllowed(accountId string) bool {
+ if _, ok := state.Config.AwsCerts.allowedAccounts[accountId]; ok {
+ return true
+ }
+ if _, ok := state.Config.AwsCerts.organisationAccounts[accountId]; ok {
+ return true
+ }
+ if _, ok := state.Config.AwsCerts.allowedAccounts["*"]; ok {
+ return true
+ }
+ return false
+}
+
+func (state *RuntimeState) refreshAwsAccounts(ctx context.Context,
+ orgClient *organizations.Client) {
+ for {
+ time.Sleep(awsAccountListInterval)
+ if list, err := awsListAccounts(ctx, orgClient); err != nil {
+ state.logger.Println(err)
+ } else {
+ oldLength := len(state.Config.AwsCerts.organisationAccounts)
+ state.Config.AwsCerts.organisationAccounts = list
+ if len(list) != oldLength {
+ state.logger.Printf(
+ "Discovered %d accounts in AWS Organisation, was %d\n",
+ len(list), oldLength)
+ }
+ }
+ }
+}
+
+func (state *RuntimeState) requestAwsRoleCertificateHandler(
+ w http.ResponseWriter, r *http.Request) {
+ state.logger.Debugln(1, "Entered requestAwsRoleCertificateHandler()")
+ if state.sendFailureToClientIfLocked(w, r) {
+ return
+ }
+ cert := state.awsCertIssuer.RequestHandler(w, r)
+ if cert != nil {
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(
+ cert.Subject.CommonName)
+ }
+}
+
+// Returns signed certificate DER.
+func (state *RuntimeState) generateRoleCert(template *x509.Certificate,
+ publicKey interface{}) ([]byte, error) {
+ strong, err := certgen.ValidatePublicKeyStrength(publicKey)
+ if err != nil {
+ return nil, err
+ }
+ if !strong {
+ return nil, fmt.Errorf("key too weak")
+ }
+ caCert, err := x509.ParseCertificate(state.caCertDer)
+ if err != nil {
+ return nil, err
+ }
+ certDER, err := x509.CreateCertificate(rand.Reader, template, caCert,
+ publicKey, state.Signer)
+ if err != nil {
+ return nil, err
+ }
+ metricLogCertDuration("x509", "granted",
+ float64(time.Until(template.NotAfter).Seconds()))
+ go func(username string, certType string) {
+ metricsMutex.Lock()
+ defer metricsMutex.Unlock()
+ certGenCounter.WithLabelValues(username, certType).Inc()
+ }(template.Subject.CommonName, "x509")
+ return certDER, nil
+}
diff --git a/cmd/keymasterd/binData.go b/cmd/keymasterd/binData.go
new file mode 100644
index 00000000..bcada58c
--- /dev/null
+++ b/cmd/keymasterd/binData.go
@@ -0,0 +1,332 @@
+// Code generated for package main by go-bindata DO NOT EDIT. (@generated)
+// sources:
+// cmd/keymasterd/data/session.js
+package main
+
+import (
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "net/http"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+func bindataRead(data []byte, name string) ([]byte, error) {
+ gz, err := gzip.NewReader(bytes.NewBuffer(data))
+ if err != nil {
+ return nil, fmt.Errorf("Read %q: %v", name, err)
+ }
+
+ var buf bytes.Buffer
+ _, err = io.Copy(&buf, gz)
+ clErr := gz.Close()
+
+ if err != nil {
+ return nil, fmt.Errorf("Read %q: %v", name, err)
+ }
+ if clErr != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+type asset struct {
+ bytes []byte
+ info os.FileInfo
+}
+
+type bindataFileInfo struct {
+ name string
+ size int64
+ mode os.FileMode
+ modTime time.Time
+}
+
+// Name return file name
+func (fi bindataFileInfo) Name() string {
+ return fi.name
+}
+
+// Size return file size
+func (fi bindataFileInfo) Size() int64 {
+ return fi.size
+}
+
+// Mode return file mode
+func (fi bindataFileInfo) Mode() os.FileMode {
+ return fi.mode
+}
+
+// Mode return file modify time
+func (fi bindataFileInfo) ModTime() time.Time {
+ return fi.modTime
+}
+
+// IsDir return file whether a directory
+func (fi bindataFileInfo) IsDir() bool {
+ return fi.mode&os.ModeDir != 0
+}
+
+// Sys return file is sys mode
+func (fi bindataFileInfo) Sys() interface{} {
+ return nil
+}
+
+
+type assetFile struct {
+ *bytes.Reader
+ name string
+ childInfos []os.FileInfo
+ childInfoOffset int
+}
+
+type assetOperator struct{}
+
+// Open implement http.FileSystem interface
+func (f *assetOperator) Open(name string) (http.File, error) {
+ var err error
+ if len(name) > 0 && name[0] == '/' {
+ name = name[1:]
+ }
+ content, err := Asset(name)
+ if err == nil {
+ return &assetFile{name: name, Reader: bytes.NewReader(content)}, nil
+ }
+ children, err := AssetDir(name)
+ if err == nil {
+ childInfos := make([]os.FileInfo, 0, len(children))
+ for _, child := range children {
+ childPath := filepath.Join(name, child)
+ info, errInfo := AssetInfo(filepath.Join(name, child))
+ if errInfo == nil {
+ childInfos = append(childInfos, info)
+ } else {
+ childInfos = append(childInfos, newDirFileInfo(childPath))
+ }
+ }
+ return &assetFile{name: name, childInfos: childInfos}, nil
+ } else {
+ // If the error is not found, return an error that will
+ // result in a 404 error. Otherwise the server returns
+ // a 500 error for files not found.
+ if strings.Contains(err.Error(), "not found") {
+ return nil, os.ErrNotExist
+ }
+ return nil, err
+ }
+}
+
+// Close no need do anything
+func (f *assetFile) Close() error {
+ return nil
+}
+
+// Readdir read dir's children file info
+func (f *assetFile) Readdir(count int) ([]os.FileInfo, error) {
+ if len(f.childInfos) == 0 {
+ return nil, os.ErrNotExist
+ }
+ if count <= 0 {
+ return f.childInfos, nil
+ }
+ if f.childInfoOffset+count > len(f.childInfos) {
+ count = len(f.childInfos) - f.childInfoOffset
+ }
+ offset := f.childInfoOffset
+ f.childInfoOffset += count
+ return f.childInfos[offset : offset+count], nil
+}
+
+// Stat read file info from asset item
+func (f *assetFile) Stat() (os.FileInfo, error) {
+ if len(f.childInfos) != 0 {
+ return newDirFileInfo(f.name), nil
+ }
+ return AssetInfo(f.name)
+}
+
+// newDirFileInfo return default dir file info
+func newDirFileInfo(name string) os.FileInfo {
+ return &bindataFileInfo{
+ name: name,
+ size: 0,
+ mode: os.FileMode(2147484068), // equal os.FileMode(0644)|os.ModeDir
+ modTime: time.Time{}}
+}
+
+// AssetFile return a http.FileSystem instance that data backend by asset
+func AssetFile() http.FileSystem {
+ return &assetOperator{}
+}
+
+var _sessionJs = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x90\x41\x4f\x02\x31\x10\x85\xef\xfd\x15\x93\x5e\x68\x15\x37\xeb\xd5\x0d\x07\x95\x3d\x90\x68\x3c\x80\x3f\xa0\xd2\x11\x9a\xb0\x53\xd2\xbe\x15\x8c\xe1\xbf\x9b\xc2\x66\x0d\x07\x6f\xcd\x74\xde\xf7\xde\xbc\xcf\x5e\xd6\x08\x51\x28\x6f\xe3\x61\xc9\x39\x87\x28\xed\x71\x1f\x92\x2b\x53\x63\xe9\x47\x11\x11\x79\x07\xa6\x19\xed\x5d\xca\xbc\x10\x18\x1f\xd7\x7d\xc7\x82\x6a\xc3\x68\x77\x5c\x9e\x4f\xdf\x0b\x6f\x74\xbe\x20\xee\xbc\x83\xd3\xb6\x7c\x3f\x02\x29\x7c\xf4\x60\xa3\x0b\x45\x5b\xdb\x9c\x91\x3c\xba\xac\x42\x57\xe0\xc2\x07\x9a\x3b\xb0\x29\x6b\x37\xf7\x75\x5d\x0f\x9b\x08\xd8\xf1\x8a\x8f\xa0\x19\xe9\x21\xe3\x45\xce\xf9\x81\xf4\xed\x35\xa9\x42\x5c\x22\x05\xd9\x98\x41\xee\x7a\x6c\xdf\x33\x27\x71\x1d\x57\xf9\x2a\xd0\x99\xac\xa7\x7f\x0e\xb6\x51\x27\xa5\xc6\xeb\x9c\xf7\xed\x17\x0b\x5e\x42\x06\x0b\x27\x33\x99\xbf\xbd\x3e\x47\x41\x99\x45\xe7\xd9\x4f\xa6\x34\x56\x38\xb6\xf5\x4f\x97\x8d\x3a\xd9\x46\xfd\x06\x00\x00\xff\xff\xc9\x75\x60\x9a\x72\x01\x00\x00")
+
+func sessionJsBytes() ([]byte, error) {
+ return bindataRead(
+ _sessionJs,
+ "session.js",
+ )
+}
+
+func sessionJs() (*asset, error) {
+ bytes, err := sessionJsBytes()
+ if err != nil {
+ return nil, err
+ }
+
+ info := bindataFileInfo{name: "session.js", size: 370, mode: os.FileMode(420), modTime: time.Unix(1634278980, 0)}
+ a := &asset{bytes: bytes, info: info}
+ return a, nil
+}
+
+// Asset loads and returns the asset for the given name.
+// It returns an error if the asset could not be found or
+// could not be loaded.
+func Asset(name string) ([]byte, error) {
+ cannonicalName := strings.Replace(name, "\\", "/", -1)
+ if f, ok := _bindata[cannonicalName]; ok {
+ a, err := f()
+ if err != nil {
+ return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
+ }
+ return a.bytes, nil
+ }
+ return nil, fmt.Errorf("Asset %s not found", name)
+}
+
+// MustAsset is like Asset but panics when Asset would return an error.
+// It simplifies safe initialization of global variables.
+func MustAsset(name string) []byte {
+ a, err := Asset(name)
+ if err != nil {
+ panic("asset: Asset(" + name + "): " + err.Error())
+ }
+
+ return a
+}
+
+// AssetInfo loads and returns the asset info for the given name.
+// It returns an error if the asset could not be found or
+// could not be loaded.
+func AssetInfo(name string) (os.FileInfo, error) {
+ cannonicalName := strings.Replace(name, "\\", "/", -1)
+ if f, ok := _bindata[cannonicalName]; ok {
+ a, err := f()
+ if err != nil {
+ return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
+ }
+ return a.info, nil
+ }
+ return nil, fmt.Errorf("AssetInfo %s not found", name)
+}
+
+// AssetNames returns the names of the assets.
+func AssetNames() []string {
+ names := make([]string, 0, len(_bindata))
+ for name := range _bindata {
+ names = append(names, name)
+ }
+ return names
+}
+
+// _bindata is a table, holding each asset generator, mapped to its name.
+var _bindata = map[string]func() (*asset, error){
+ "session.js": sessionJs,
+}
+
+// AssetDir returns the file names below a certain
+// directory embedded in the file by go-bindata.
+// For example if you run go-bindata on data/... and data contains the
+// following hierarchy:
+// data/
+// foo.txt
+// img/
+// a.png
+// b.png
+// then AssetDir("data") would return []string{"foo.txt", "img"}
+// AssetDir("data/img") would return []string{"a.png", "b.png"}
+// AssetDir("foo.txt") and AssetDir("notexist") would return an error
+// AssetDir("") will return []string{"data"}.
+func AssetDir(name string) ([]string, error) {
+ node := _bintree
+ if len(name) != 0 {
+ cannonicalName := strings.Replace(name, "\\", "/", -1)
+ pathList := strings.Split(cannonicalName, "/")
+ for _, p := range pathList {
+ node = node.Children[p]
+ if node == nil {
+ return nil, fmt.Errorf("Asset %s not found", name)
+ }
+ }
+ }
+ if node.Func != nil {
+ return nil, fmt.Errorf("Asset %s not found", name)
+ }
+ rv := make([]string, 0, len(node.Children))
+ for childName := range node.Children {
+ rv = append(rv, childName)
+ }
+ return rv, nil
+}
+
+type bintree struct {
+ Func func() (*asset, error)
+ Children map[string]*bintree
+}
+
+var _bintree = &bintree{nil, map[string]*bintree{
+ "session.js": &bintree{sessionJs, map[string]*bintree{}},
+}}
+
+// RestoreAsset restores an asset under the given directory
+func RestoreAsset(dir, name string) error {
+ data, err := Asset(name)
+ if err != nil {
+ return err
+ }
+ info, err := AssetInfo(name)
+ if err != nil {
+ return err
+ }
+ err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
+ if err != nil {
+ return err
+ }
+ err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode())
+ if err != nil {
+ return err
+ }
+ err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// RestoreAssets restores an asset under the given directory recursively
+func RestoreAssets(dir, name string) error {
+ children, err := AssetDir(name)
+ // File
+ if err != nil {
+ return RestoreAsset(dir, name)
+ }
+ // Dir
+ for _, child := range children {
+ err = RestoreAssets(dir, filepath.Join(name, child))
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func _filePath(dir, name string) string {
+ cannonicalName := strings.Replace(name, "\\", "/", -1)
+ return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
+}
diff --git a/cmd/keymasterd/certgen.go b/cmd/keymasterd/certgen.go
index 52bc94fc..5b1d8b4e 100644
--- a/cmd/keymasterd/certgen.go
+++ b/cmd/keymasterd/certgen.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
+ "os"
"regexp"
"strings"
"time"
@@ -15,11 +16,15 @@ import (
"github.com/Cloud-Foundations/keymaster/lib/authutil"
"github.com/Cloud-Foundations/keymaster/lib/certgen"
"github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+ "github.com/Cloud-Foundations/keymaster/lib/util"
"github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto"
"golang.org/x/crypto/ssh"
)
-const certgenPath = "/certgen/"
+const (
+ certgenPath = "/certgen/"
+ maxCertificateLifetime = time.Hour * 24
+)
func prependGroups(groups []string, prefix string) []string {
if prefix == "" {
@@ -53,13 +58,15 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request
/*
*/
// TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds
- authUser, authLevel, err := state.checkAuth(w, r, AuthTypeAny)
+ authData, err := state.checkAuth(w, r, AuthTypeAny)
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
- logger.Debugf(1, "Certgen, authenticated at level=%x, username=`%s`", authLevel, authUser)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
+ logger.Debugf(1,
+ "Certgen, authenticated at level=%x, username=`%s`, expires=%s",
+ authData.AuthType, authData.Username, authData.ExpiresAt)
sufficientAuthLevel := false
// We should do an intersection operation here
@@ -67,40 +74,53 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request
if certPref == proto.AuthTypePassword {
sufficientAuthLevel = true
}
- if certPref == proto.AuthTypeU2F && ((authLevel & AuthTypeU2F) == AuthTypeU2F) {
+ if certPref == proto.AuthTypeU2F &&
+ ((authData.AuthType & AuthTypeU2F) == AuthTypeU2F) {
+ sufficientAuthLevel = true
+ }
+ if certPref == proto.AuthTypeTOTP &&
+ ((authData.AuthType & AuthTypeTOTP) == AuthTypeTOTP) {
sufficientAuthLevel = true
}
- if certPref == proto.AuthTypeTOTP && ((authLevel & AuthTypeTOTP) == AuthTypeTOTP) {
+ if certPref == proto.AuthTypeSymantecVIP &&
+ ((authData.AuthType & AuthTypeSymantecVIP) == AuthTypeSymantecVIP) {
sufficientAuthLevel = true
}
- if certPref == proto.AuthTypeSymantecVIP && ((authLevel & AuthTypeSymantecVIP) == AuthTypeSymantecVIP) {
+ if certPref == proto.AuthTypeIPCertificate &&
+ ((authData.AuthType & AuthTypeIPCertificate) == AuthTypeIPCertificate) {
sufficientAuthLevel = true
}
- if certPref == proto.AuthTypeIPCertificate && ((authLevel & AuthTypeIPCertificate) == AuthTypeIPCertificate) {
+ if certPref == proto.AuthTypeOkta2FA &&
+ ((authData.AuthType & AuthTypeOkta2FA) == AuthTypeOkta2FA) {
sufficientAuthLevel = true
}
- if certPref == proto.AuthTypeOkta2FA && ((authLevel & AuthTypeOkta2FA) == AuthTypeOkta2FA) {
+ if certPref == proto.AuthTypeWebauthForCLI &&
+ ((authData.AuthType & AuthTypeWebauthForCLI) ==
+ AuthTypeWebauthForCLI) {
sufficientAuthLevel = true
}
}
// if you have u2f you can always get the cert
- if (authLevel & AuthTypeU2F) == AuthTypeU2F {
+ if (authData.AuthType & AuthTypeU2F) == AuthTypeU2F {
sufficientAuthLevel = true
}
if !sufficientAuthLevel {
logger.Printf("Not enough auth level for getting certs")
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Not enough auth level for getting certs")
+ state.writeFailureResponse(w, r, http.StatusUnauthorized,
+ "Not enough auth level for getting certs")
return
}
targetUser := r.URL.Path[len(certgenPath):]
- if authUser != targetUser {
+ if authData.Username != targetUser {
state.writeFailureResponse(w, r, http.StatusForbidden, "")
- logger.Printf("User %s asking for creds for %s", authUser, targetUser)
+ logger.Debugf(1, "User %s asking for creds for %s",
+ authData.Username, targetUser)
return
}
- logger.Debugf(3, "auth succedded for %s", authUser)
+ targetUser = authData.Username
+ logger.Debugf(3, "auth succedded for %s", authData.Username)
if r.Method != "POST" {
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
@@ -114,7 +134,7 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request
state.writeFailureResponse(w, r, http.StatusBadRequest, "Error parsing form")
return
}
- duration := time.Duration(24 * time.Hour)
+ duration := maxCertificateLifetime
if formDuration, ok := r.Form["duration"]; ok {
stringDuration := formDuration[0]
newDuration, err := time.ParseDuration(stringDuration)
@@ -131,12 +151,16 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request
}
duration = newDuration
}
+ maxDuration := time.Until(authData.IssuedAt.Add(maxCertificateLifetime))
+ if duration > maxDuration {
+ duration = maxDuration
+ }
certType := "ssh"
if val, ok := r.Form["type"]; ok {
certType = val[0]
}
- logger.Printf("cert type =%s", certType)
+ logger.Debugf(1, "cert type =%s", certType)
switch certType {
case "ssh":
@@ -183,6 +207,24 @@ func getValidSSHPublicKey(userPubKey string) (ssh.PublicKey, error, error) {
return userSSH, nil, nil
}
+func (state *RuntimeState) expandSSHExtensions(username string) (map[string]string, error) {
+ mapper := func(placeholderName string) string {
+ switch placeholderName {
+ case "USERNAME":
+ return username
+ }
+ return ""
+ }
+ userExtensions := make(map[string]string)
+ for _, extension := range state.Config.Base.SSHCertConfig.Extensions {
+ key := os.Expand(extension.Key, mapper)
+ value := os.Expand(extension.Value, mapper)
+ userExtensions[key] = value
+ }
+
+ return userExtensions, nil
+}
+
func (state *RuntimeState) postAuthSSHCertHandler(
w http.ResponseWriter, r *http.Request, targetUser string,
duration time.Duration) {
@@ -207,7 +249,7 @@ func (state *RuntimeState) postAuthSSHCertHandler(
sshUserPublicKey, userErr, err := getValidSSHPublicKey(userPubKey)
if err != nil {
- logger.Println(err)
+ logger.Debugf(1, "postAuthSSHCertHandler: getValidSSHPublicKey err=%s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
return
}
@@ -234,8 +276,13 @@ func (state *RuntimeState) postAuthSSHCertHandler(
logger.Printf("Signer failed to load")
return
}
-
- certString, cert, err = certgen.GenSSHCertFileString(targetUser, userPubKey, signer, state.HostIdentity, duration)
+ extensions, err := state.expandSSHExtensions(targetUser)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ logger.Printf("Extensions Failed to expand")
+ return
+ }
+ certString, cert, err = certgen.GenSSHCertFileString(targetUser, userPubKey, signer, state.HostIdentity, duration, extensions)
if err != nil {
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
logger.Printf("signUserPubkey Err")
@@ -244,11 +291,13 @@ func (state *RuntimeState) postAuthSSHCertHandler(
eventNotifier.PublishSSH(cert.Marshal())
metricLogCertDuration("ssh", "granted", float64(duration.Seconds()))
+ clientIpAddress := util.GetRequestRealIp(r)
w.Header().Set("Content-Disposition", "attachment; filename=\""+cert.Type()+"-cert.pub\"")
w.WriteHeader(200)
fmt.Fprintf(w, "%s", certString)
- logger.Printf("Generated SSH Certifcate for %s. Serial:%d", targetUser, cert.Serial)
+ logger.Printf("Generated SSH Certificate for %s (from %s) . Serial: %d",
+ targetUser, clientIpAddress, cert.Serial)
go func(username string, certType string) {
metricsMutex.Lock()
defer metricsMutex.Unlock()
@@ -311,6 +360,14 @@ func (state *RuntimeState) getUserGroups(username string) ([]string, error) {
return nil, nil
}
+func (state *RuntimeState) getServiceMethods(username string) (
+ []string, error) {
+ if state.gitDB == nil {
+ return nil, nil
+ }
+ return state.gitDB.GetUserServiceMethods(username)
+}
+
func (state *RuntimeState) postAuthX509CertHandler(
w http.ResponseWriter, r *http.Request, targetUser string,
keySigner crypto.Signer, duration time.Duration,
@@ -324,7 +381,7 @@ func (state *RuntimeState) postAuthX509CertHandler(
logger.Debugf(2, "Groups needed for cert")
userGroups, err = state.getUserGroups(targetUser)
if err != nil {
- logger.Println(err)
+ logger.Printf("Cannot get user groups: %s\n", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
return
}
@@ -336,73 +393,88 @@ func (state *RuntimeState) postAuthX509CertHandler(
if kubernetesHack {
organizations = userGroups
}
+ serviceMethods, err := state.getServiceMethods(targetUser)
+ if err != nil {
+ logger.Println(err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ return
+ }
var cert string
- switch r.Method {
- case "POST":
- file, _, err := r.FormFile("pubkeyfile")
- if err != nil {
- logger.Println(err)
- state.writeFailureResponse(w, r, http.StatusBadRequest,
- "Missing public key file")
- return
- }
- defer file.Close()
- buf := new(bytes.Buffer)
- buf.ReadFrom(file)
-
- block, _ := pem.Decode(buf.Bytes())
- if block == nil || block.Type != "PUBLIC KEY" {
- state.writeFailureResponse(w, r, http.StatusBadRequest,
- "Invalid File, Unable to decode pem")
- logger.Printf("invalid file, unable to decode pem")
- return
- }
- userPub, err := x509.ParsePKIXPublicKey(block.Bytes)
- if err != nil {
- state.writeFailureResponse(w, r, http.StatusBadRequest,
- "Cannot parse public key")
- logger.Printf("Cannot parse public key")
- return
- }
- validKey, err := certgen.ValidatePublicKeyStrength(userPub)
- if err != nil {
- logger.Println(err)
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
- return
- }
- if !validKey {
- state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid File, Check Key strength/key type")
- logger.Printf("Invalid File, Check Key strength/key type")
- return
- }
- caCert, err := x509.ParseCertificate(state.caCertDer)
- if err != nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
- logger.Printf("Cannot parse CA Der data")
- return
- }
- derCert, err := certgen.GenUserX509Cert(targetUser, userPub, caCert,
- keySigner, state.KerberosRealm, duration, groups, organizations)
- if err != nil {
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
- logger.Printf("Cannot Generate x509cert")
- return
- }
- eventNotifier.PublishX509(derCert)
- cert = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE",
- Bytes: derCert}))
-
- default:
+ if r.Method != "POST" {
state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "")
return
+ }
+
+ file, _, err := r.FormFile("pubkeyfile")
+ if err != nil {
+ logger.Printf("Cannot get public key from form: %s\n", err)
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Missing public key file")
+ return
+ }
+ defer file.Close()
+ buf := new(bytes.Buffer)
+ buf.ReadFrom(file)
+ block, _ := pem.Decode(buf.Bytes())
+ if block == nil || block.Type != "PUBLIC KEY" {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Invalid File, Unable to decode pem")
+ logger.Printf("invalid file, unable to decode pem")
+ return
+ }
+ userPub, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusBadRequest,
+ "Cannot parse public key")
+ logger.Printf("Cannot parse public key: %s\n", err)
+ return
}
+ validKey, err := certgen.ValidatePublicKeyStrength(userPub)
+ if err != nil {
+ logger.Printf("Cannot validate public key strength: %s\n", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if !validKey {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid File, Check Key strength/key type")
+ logger.Printf("Invalid File, Check Key strength/key type")
+ return
+ }
+ caCert, err := x509.ParseCertificate(state.caCertDer)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ logger.Printf("Cannot parse CA Der: %s\n data", err)
+ return
+ }
+ derCert, err := certgen.GenUserX509Cert(targetUser, userPub, caCert,
+ keySigner, state.KerberosRealm, duration, groups, organizations,
+ serviceMethods, logger)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ logger.Printf("Cannot Generate x509cert: %s\n", err)
+ return
+ }
+ parsedCert, err := x509.ParseCertificate(derCert)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ logger.Printf("Cannot Parse Generated x509cert: %s\n", err)
+ return
+ }
+
+ eventNotifier.PublishX509(derCert)
+ cert = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE",
+ Bytes: derCert}))
+
metricLogCertDuration("x509", "granted", float64(duration.Seconds()))
+ clientIpAddress := util.GetRequestRealIp(r)
+
w.Header().Set("Content-Disposition", `attachment; filename="userCert.pem"`)
w.WriteHeader(200)
fmt.Fprintf(w, "%s", cert)
- logger.Printf("Generated x509 Certifcate for %s", targetUser)
+ logger.Printf("Generated x509 Certificate for %s (from %s). Serial: %s",
+ targetUser, clientIpAddress, parsedCert.SerialNumber.String())
go func(username string, certType string) {
metricsMutex.Lock()
defer metricsMutex.Unlock()
diff --git a/cmd/keymasterd/certgen_test.go b/cmd/keymasterd/certgen_test.go
index e8bd4b87..9bfdd8a5 100644
--- a/cmd/keymasterd/certgen_test.go
+++ b/cmd/keymasterd/certgen_test.go
@@ -52,7 +52,7 @@ const invalidSSHFileBadKeyData = `ssh-rsa AAAAB3NzaC1kc3dddMAAACBALd5BLQoXxeJHHM
const testDuration = time.Duration(120 * time.Second)
-/// X509section (this is from certgen TODO: make public)
+// X509section (this is from certgen TODO: make public)
func getPubKeyFromPem(pubkey string) (pub interface{}, err error) {
block, rest := pem.Decode([]byte(pubkey))
if block == nil || block.Type != "PUBLIC KEY" {
@@ -226,3 +226,44 @@ func TestGenSSHEd25519(t *testing.T) {
}
}
+
+func TestExpandSSHExtensions(t *testing.T) {
+ state, passwdFile, err := setupValidRuntimeStateSigner(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(passwdFile.Name()) // clean up
+ state.Config.Base.SSHCertConfig.Extensions = []sshExtension{
+ sshExtension{
+ Key: "user:username",
+ Value: "$USERNAME",
+ },
+ sshExtension{
+ Key: "key:$USERNAME",
+ Value: "value:userkey",
+ },
+ }
+ extensions, err := state.expandSSHExtensions("username")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if extensions == nil {
+ t.Fatal("nil extension")
+ }
+ compareMap := map[string]string{
+ "user:username": "username",
+ "key:username": "value:userkey",
+ }
+ if len(state.Config.Base.SSHCertConfig.Extensions) != len(extensions) {
+ t.Fatal("incomplete expansion")
+ }
+ for key, value := range extensions {
+ cValue, ok := compareMap[key]
+ if !ok {
+ t.Fatal("key not found")
+ }
+ if value != cValue {
+ t.Fatal("value does not match")
+ }
+ }
+}
diff --git a/cmd/keymasterd/config.go b/cmd/keymasterd/config.go
index a37349b3..6252ca3b 100644
--- a/cmd/keymasterd/config.go
+++ b/cmd/keymasterd/config.go
@@ -17,6 +17,7 @@ import (
"io/ioutil"
"math/big"
"net"
+ "net/http"
"os"
"path/filepath"
"regexp"
@@ -34,13 +35,17 @@ import (
"github.com/Cloud-Foundations/keymaster/keymasterd/admincache"
"github.com/Cloud-Foundations/keymaster/lib/authenticators/okta"
"github.com/Cloud-Foundations/keymaster/lib/pwauth/command"
+ "github.com/Cloud-Foundations/keymaster/lib/pwauth/htpassword"
"github.com/Cloud-Foundations/keymaster/lib/pwauth/ldap"
+ "github.com/Cloud-Foundations/keymaster/lib/server/aws_identity_cert"
"github.com/Cloud-Foundations/keymaster/lib/vip"
+ "github.com/duo-labs/webauthn/webauthn"
"github.com/howeyc/gopass"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
"golang.org/x/crypto/ssh"
"golang.org/x/oauth2"
+ "golang.org/x/time/rate"
"gopkg.in/yaml.v2"
)
@@ -49,37 +54,56 @@ type autoUnseal struct {
AwsSecretKey string `yaml:"aws_secret_key"`
}
+type sshExtension struct {
+ Key string `yaml:"key"`
+ Value string `yaml:"value"`
+}
+
+type sshCertConfig struct {
+ Extensions []sshExtension `yaml:"extensions"`
+}
+
type baseConfig struct {
- HttpAddress string `yaml:"http_address"`
- AdminAddress string `yaml:"admin_address"`
- HttpRedirectPort uint16 `yaml:"http_redirect_port"`
- TLSCertFilename string `yaml:"tls_cert_filename"`
- TLSKeyFilename string `yaml:"tls_key_filename"`
- ACME acmecfg.AcmeConfig
- SSHCAFilename string `yaml:"ssh_ca_filename"`
- Ed25519CAFilename string `yaml:"ed25519_ca_keyfilename"`
- AutoUnseal autoUnseal `yaml:"auto_unseal"`
- HtpasswdFilename string `yaml:"htpasswd_filename"`
- ExternalAuthCmd string `yaml:"external_auth_command"`
- ClientCAFilename string `yaml:"client_ca_filename"`
- KeymasterPublicKeysFilename string `yaml:"keymaster_public_keys_filename"`
- HostIdentity string `yaml:"host_identity"`
- KerberosRealm string `yaml:"kerberos_realm"`
- DataDirectory string `yaml:"data_directory"`
- SharedDataDirectory string `yaml:"shared_data_directory"`
- HideStandardLogin bool `yaml:"hide_standard_login"`
- AllowedAuthBackendsForCerts []string `yaml:"allowed_auth_backends_for_certs"`
- AllowedAuthBackendsForWebUI []string `yaml:"allowed_auth_backends_for_webui"`
- AllowSelfServiceBootstrapOTP bool `yaml:"allow_self_service_bootstrap_otp"`
- AdminUsers []string `yaml:"admin_users"`
- AdminGroups []string `yaml:"admin_groups"`
- PublicLogs bool `yaml:"public_logs"`
- SecsBetweenDependencyChecks int `yaml:"secs_between_dependency_checks"`
- AutomationUserGroups []string `yaml:"automation_user_groups"`
- AutomationUsers []string `yaml:"automation_users"`
- DisableUsernameNormalization bool `yaml:"disable_username_normalization"`
- EnableLocalTOTP bool `yaml:"enable_local_totp"`
- EnableBootstrapOTP bool `yaml:"enable_bootstrapotp"`
+ HttpAddress string `yaml:"http_address"`
+ AdminAddress string `yaml:"admin_address"`
+ HttpRedirectPort uint16 `yaml:"http_redirect_port"`
+ TLSCertFilename string `yaml:"tls_cert_filename"`
+ TLSKeyFilename string `yaml:"tls_key_filename"`
+ ACME acmecfg.AcmeConfig
+ SSHCAFilename string `yaml:"ssh_ca_filename"`
+ Ed25519CAFilename string `yaml:"ed25519_ca_keyfilename"`
+ AutoUnseal autoUnseal `yaml:"auto_unseal"`
+ HtpasswdFilename string `yaml:"htpasswd_filename"`
+ ExternalAuthCmd string `yaml:"external_auth_command"`
+ ClientCAFilename string `yaml:"client_ca_filename"`
+ KeymasterPublicKeysFilename string `yaml:"keymaster_public_keys_filename"`
+ HostIdentity string `yaml:"host_identity"`
+ KerberosRealm string `yaml:"kerberos_realm"`
+ DataDirectory string `yaml:"data_directory"`
+ SharedDataDirectory string `yaml:"shared_data_directory"`
+ AllowedAuthBackendsForCerts []string `yaml:"allowed_auth_backends_for_certs"`
+ AllowedAuthBackendsForWebUI []string `yaml:"allowed_auth_backends_for_webui"`
+ AllowSelfServiceBootstrapOTP bool `yaml:"allow_self_service_bootstrap_otp"`
+ AdminUsers []string `yaml:"admin_users"`
+ AdminGroups []string `yaml:"admin_groups"`
+ PublicLogs bool `yaml:"public_logs"`
+ SecsBetweenDependencyChecks int `yaml:"secs_between_dependency_checks"`
+ AutomationUserGroups []string `yaml:"automation_user_groups"`
+ AutomationUsers []string `yaml:"automation_users"`
+ DisableUsernameNormalization bool `yaml:"disable_username_normalization"`
+ EnableLocalTOTP bool `yaml:"enable_local_totp"`
+ EnableBootstrapOTP bool `yaml:"enable_bootstrapotp"`
+ WebauthTokenForCliLifetime time.Duration `yaml:"webauth_token_for_cli_lifetime"`
+ PasswordAttemptGlobalBurstLimit uint `yaml:"password_attempt_global_burst_limit"`
+ PasswordAttemptGlobalRateLimit rate.Limit `yaml:"password_attempt_global_rate_limit"`
+ SSHCertConfig sshCertConfig `yaml:"ssh_cert_config"`
+}
+
+type awsCertsConfig struct {
+ AllowedAccounts []string `yaml:"allowed_accounts"`
+ ListAccountsRole string `yaml:"list_accounts_role"`
+ allowedAccounts map[string]struct{}
+ organisationAccounts map[string]struct{}
}
type emailConfig struct {
@@ -122,22 +146,24 @@ type UserInfoSouces struct {
}
type Oauth2Config struct {
- Config *oauth2.Config
- Enabled bool `yaml:"enabled"`
- ClientID string `yaml:"client_id"`
- ClientSecret string `yaml:"client_secret"`
- TokenUrl string `yaml:"token_url"`
- AuthUrl string `yaml:"auth_url"`
- UserinfoUrl string `yaml:"userinfo_url"`
- Scopes string `yaml:"scopes"`
+ Config *oauth2.Config
+ Enabled bool `yaml:"enabled"`
+ ForceRedirect bool `yaml:"force_redirect"`
+ ClientID string `yaml:"client_id"`
+ ClientSecret string `yaml:"client_secret"`
+ TokenUrl string `yaml:"token_url"`
+ AuthUrl string `yaml:"auth_url"`
+ UserinfoUrl string `yaml:"userinfo_url"`
+ Scopes string `yaml:"scopes"`
//Todo add allowed orgs...
}
type OpenIDConnectClientConfig struct {
- ClientID string `yaml:"client_id"`
- ClientSecret string `yaml:"client_secret"`
- AllowedRedirectURLRE []string `yaml:"allowed_redirect_url_re"`
- AllowedRedirectDomains []string `yaml:"allowed_redirect_domains"`
+ ClientID string `yaml:"client_id"`
+ ClientSecret string `yaml:"client_secret"`
+ AllowClientChosenAudiences bool `yaml:"allow_client_chose_audiences"`
+ AllowedRedirectURLRE []string `yaml:"allowed_redirect_url_re"`
+ AllowedRedirectDomains []string `yaml:"allowed_redirect_domains"`
}
type OpenIDConnectIDPConfig struct {
@@ -164,6 +190,7 @@ type SymantecVIPConfig struct {
type AppConfigFile struct {
Base baseConfig
+ AwsCerts awsCertsConfig `yaml:"aws_certs"`
DnsLoadBalancer dnslbcfg.Config `yaml:"dns_load_balancer"`
Watchdog watchdog.Config `yaml:"watchdog"`
Email emailConfig
@@ -201,7 +228,7 @@ func (state *RuntimeState) loadTemplates() (err error) {
// Load the built-in HTML templates.
htmlTemplates := []string{footerTemplateText, loginFormText,
secondFactorAuthFormText, profileHTML, usersHTML, headerTemplateText,
- newTOTPHTML, newBootstrapOTPPHTML,
+ newTOTPHTML, newBootstrapOTPPHTML, showAuthTokenHTML,
}
for _, templateString := range htmlTemplates {
_, err = state.htmlTemplate.Parse(templateString)
@@ -357,6 +384,8 @@ func loadVerifyConfigFile(configFilename string,
}
runtimeState.initEmailDefaults()
runtimeState.Config.Watchdog.SetDefaults()
+ runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit = 100
+ runtimeState.Config.Base.PasswordAttemptGlobalRateLimit = 10
if _, err := os.Stat(configFilename); os.IsNotExist(err) {
err = errors.New("mising config file failure")
return nil, err
@@ -369,6 +398,15 @@ func loadVerifyConfigFile(configFilename string,
if err != nil {
return nil, fmt.Errorf("cannot parse config file: %s", err)
}
+ if runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit < 10 {
+ runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit = 10
+ }
+ if runtimeState.Config.Base.PasswordAttemptGlobalRateLimit < 1 {
+ runtimeState.Config.Base.PasswordAttemptGlobalRateLimit = 1
+ }
+ runtimeState.passwordAttemptGlobalLimiter = rate.NewLimiter(
+ runtimeState.Config.Base.PasswordAttemptGlobalRateLimit,
+ int(runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit))
//share config
//runtimeState.userProfile = make(map[string]userProfile)
@@ -397,6 +435,15 @@ func loadVerifyConfigFile(configFilename string,
u2fAppID = u2fAppID + runtimeState.Config.Base.HttpAddress
}
u2fTrustedFacets = append(u2fTrustedFacets, u2fAppID)
+ runtimeState.webAuthn, err = webauthn.New(&webauthn.Config{
+ RPDisplayName: "Keymaster Server", // Display Name for your site
+ RPID: runtimeState.HostIdentity, // Generally the domain name for your site
+ RPOrigin: u2fAppID, // The origin URL for WebAuthn requests
+ // RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site
+ })
+ if err != nil {
+ return nil, err
+ }
if len(runtimeState.Config.Base.KerberosRealm) > 0 {
runtimeState.KerberosRealm = &runtimeState.Config.Base.KerberosRealm
@@ -508,12 +555,6 @@ func loadVerifyConfigFile(configFilename string,
runtimeState.Config.SymantecVIP.Client = &client
}
- //
- if runtimeState.Config.Base.HideStandardLogin && !runtimeState.Config.Oauth2.Enabled {
- err := errors.New("invalid configuration... cannot hide std login without enabling oath2")
- return nil, err
- }
-
//Load extra templates
err = runtimeState.loadTemplates()
if err != nil {
@@ -525,9 +566,17 @@ func loadVerifyConfigFile(configFilename string,
// TODO(rgooch): We should probably support a priority list of
// authentication backends which are tried in turn. The current scheme is
// hacky and is limited to only one authentication backend.
+ if runtimeState.Config.Base.HtpasswdFilename != "" {
+ runtimeState.passwordChecker, err = htpassword.New(
+ runtimeState.Config.Base.HtpasswdFilename, logger)
+ if err != nil {
+ return nil, err
+ }
+ }
// ExtAuthCommand
if len(runtimeState.Config.Base.ExternalAuthCmd) > 0 {
- runtimeState.passwordChecker, err = command.New(runtimeState.Config.Base.ExternalAuthCmd, nil, logger)
+ runtimeState.passwordChecker, err = command.New(
+ runtimeState.Config.Base.ExternalAuthCmd, nil, logger)
if err != nil {
return nil, err
}
@@ -565,10 +614,20 @@ func loadVerifyConfigFile(configFilename string,
}
logger.Debugf(1, "passwordChecker= %+v", runtimeState.passwordChecker)
}
+ // If not using an OAuth2 IDP for primary authentication, must have an
+ // alternative enabled.
+ if runtimeState.passwordChecker == nil &&
+ !runtimeState.Config.Oauth2.Enabled {
+ return nil, errors.New(
+ "invalid configuration: no primary authentication method")
+ }
+
if runtimeState.Config.Base.SecsBetweenDependencyChecks < 1 {
runtimeState.Config.Base.SecsBetweenDependencyChecks = defaultSecsBetweenDependencyChecks
}
-
+ if err := runtimeState.configureAwsRoles(); err != nil {
+ return nil, err
+ }
logger.Debugf(1, "End of config initialization: %+v", &runtimeState)
// UserInfo setup.
@@ -582,6 +641,12 @@ func loadVerifyConfigFile(configFilename string,
logger.Println("loaded UserInfo GitDB")
}
+ if runtimeState.Config.Base.WebauthTokenForCliLifetime >
+ maxWebauthForCliTokenLifetime {
+ runtimeState.Config.Base.WebauthTokenForCliLifetime =
+ maxWebauthForCliTokenLifetime
+ }
+
// Warn on potential issues
warnInsecureConfiguration(&runtimeState)
@@ -590,6 +655,21 @@ func loadVerifyConfigFile(configFilename string,
return nil, err
}
+ failureWriter := func(w http.ResponseWriter, r *http.Request,
+ errorString string, code int) {
+ runtimeState.writeFailureResponse(w, r, code, errorString)
+ }
+ runtimeState.awsCertIssuer, err = aws_identity_cert.New(
+ aws_identity_cert.Params{
+ CertificateGenerator: runtimeState.generateRoleCert,
+ AccountIdValidator: runtimeState.checkAwsAccountAllowed,
+ FailureWriter: failureWriter,
+ Logger: logger,
+ })
+ if err != nil {
+ return nil, err
+ }
+
// and we start the cleanup
go runtimeState.performStateCleanup(secsBetweenCleanup)
diff --git a/cmd/keymasterd/data/session.js b/cmd/keymasterd/data/session.js
new file mode 100644
index 00000000..b3ea72f2
--- /dev/null
+++ b/cmd/keymasterd/data/session.js
@@ -0,0 +1,10 @@
+function showSessionExpiration() {
+ date = parseInt(document.getElementById("session-data").getAttribute("date"));
+ expirationTime = new Date(date*1000);
+ titleText = "Session expires: "+expirationTime.toString();
+ authUsername.setAttribute("title", titleText);
+}
+
+document.addEventListener('DOMContentLoaded', function () {
+ showSessionExpiration();
+});
diff --git a/cmd/keymasterd/idp_oidc.go b/cmd/keymasterd/idp_oidc.go
index 99d234d1..41eadcd3 100644
--- a/cmd/keymasterd/idp_oidc.go
+++ b/cmd/keymasterd/idp_oidc.go
@@ -19,7 +19,6 @@ import (
"github.com/Cloud-Foundations/keymaster/lib/authutil"
"github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
- "github.com/mendsley/gojwk"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)
@@ -77,29 +76,23 @@ func (state *RuntimeState) idpOpenIDCDiscoveryHandler(w http.ResponseWriter, r *
out.WriteTo(w)
}
-type jwsKeyList struct {
- Keys []*gojwk.Key `json:"keys"`
-}
-
// Need to improve this to account for adding the other signers here.
func (state *RuntimeState) idpOpenIDCJWKSHandler(w http.ResponseWriter, r *http.Request) {
if state.sendFailureToClientIfLocked(w, r) {
return
}
- var currentKeys jwsKeyList
+ var currentKeys jose.JSONWebKeySet
for _, key := range state.KeymasterPublicKeys {
- jwkKey, err := gojwk.PublicKey(key)
- if err != nil {
- log.Printf("error getting key idpOpenIDCJWKSHandler: %s", err)
- state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error")
- return
- }
- jwkKey.Kid, err = getKeyFingerprint(key)
+ kid, err := getKeyFingerprint(key)
if err != nil {
log.Printf("error computing key fingerprint in idpOpenIDCJWKSHandler: %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error")
return
}
+ jwkKey := jose.JSONWebKey{
+ Key: key,
+ KeyID: kid,
+ }
currentKeys.Keys = append(currentKeys.Keys, jwkKey)
}
b, err := json.Marshal(currentKeys)
@@ -122,20 +115,33 @@ type keymasterdIDPCodeProtectedData struct {
}
type keymasterdCodeToken struct {
- Issuer string `json:"iss"` //keymasterd
- Subject string `json:"sub"` //clientID
- IssuedAt int64 `json:"iat"`
- Expiration int64 `json:"exp"`
- Username string `json:"username"`
- AuthLevel int64 `json:"auth_level"`
- AuthExpiration int64 `json:"auth_exp"`
- Nonce string `json:"nonce,omitEmpty"`
- RedirectURI string `json:"redirect_uri"`
- Scope string `json:"scope"`
- Type string `json:"type"`
- JWTId string `json:"jti,omitEmpty"`
- ProtectedDataKey string `json:"protected_data_key,omitempty"`
- ProtectedData string `json:"protected_data,omitempty"`
+ Issuer string `json:"iss"` //keymasterd
+ Subject string `json:"sub"` //clientID
+ IssuedAt int64 `json:"iat"`
+ Expiration int64 `json:"exp"`
+ Audience []string `json:"aud"`
+ Username string `json:"username"`
+ AuthLevel int64 `json:"auth_level"`
+ AuthExpiration int64 `json:"auth_exp"`
+ Nonce string `json:"nonce,omitEmpty"`
+ RedirectURI string `json:"redirect_uri"`
+ AccessAudience []string `json:"access_audience,omitempty"`
+ Scope string `json:"scope"`
+ Type string `json:"type"`
+ JWTId string `json:"jti,omitEmpty"`
+ ProtectedDataKey string `json:"protected_data_key,omitempty"`
+ ProtectedData string `json:"protected_data,omitempty"`
+}
+
+var ErrorIDPClientNotFound = errors.New("Client id not found")
+
+func (state *RuntimeState) idpOpenIDCGetClientConfig(client_id string) (*OpenIDConnectClientConfig, error) {
+ for _, client := range state.Config.OpenIDConnectIDP.Client {
+ if client.ClientID == client_id {
+ return &client, nil
+ }
+ }
+ return nil, ErrorIDPClientNotFound
}
// https://tools.ietf.org/id/draft-ietf-oauth-security-topics-10.html states
@@ -145,60 +151,85 @@ type keymasterdCodeToken struct {
// 1. redirect_urls scheme MUST be https (to prevent code snooping).
// 2. redirect_urls MUST not include a query (to prevent stealing of code with faulty clients (open redirect))
// 3. redirect_url path MUST NOT contain ".." to prevent path traversal attacks
-func (state *RuntimeState) idpOpenIDCClientCanRedirect(client_id string, redirect_url string) (bool, error) {
- for _, client := range state.Config.OpenIDConnectIDP.Client {
- if client.ClientID != client_id {
- continue
- }
- if len(client.AllowedRedirectDomains) < 1 && len(client.AllowedRedirectURLRE) < 1 {
- return false, nil
- }
- matchedRE := false
- for _, re := range client.AllowedRedirectURLRE {
- matched, err := regexp.MatchString(re, redirect_url)
- if err != nil {
- return false, err
- }
- if matched {
- matchedRE = true
- break
- }
- }
- parsedURL, err := url.Parse(redirect_url)
+func (client *OpenIDConnectClientConfig) CanRedirectToURL(redirectUrl string) (bool, *url.URL, error) {
+ if len(client.AllowedRedirectDomains) < 1 && len(client.AllowedRedirectURLRE) < 1 {
+ return false, nil, nil
+ }
+ matchedRE := false
+ for _, re := range client.AllowedRedirectURLRE {
+ matched, err := regexp.MatchString(re, redirectUrl)
if err != nil {
- logger.Debugf(1, "user passed unparsable url as string err = %s", err)
- return false, nil
- }
- if parsedURL.Scheme != "https" {
- return false, nil
+ return false, nil, err
}
- if len(parsedURL.RawQuery) > 0 {
- return false, nil
- }
- if strings.Contains(parsedURL.Path, "..") {
- return false, nil
- }
- // if no domains, the matchedRE answer is authoritative
- if len(client.AllowedRedirectDomains) < 1 {
- return matchedRE, nil
- }
- if len(client.AllowedRedirectURLRE) < 1 {
+ if matched {
matchedRE = true
+ break
}
- matchedDomain := false
- for _, domain := range client.AllowedRedirectDomains {
- matched := strings.HasSuffix(parsedURL.Hostname(), domain)
- if matched {
- matchedDomain = true
- break
- }
+ }
+ parsedURL, err := url.Parse(redirectUrl)
+ if err != nil {
+ logger.Debugf(1, "user passed unparsable url as string err = %s", err)
+ return false, nil, nil
+ }
+ if parsedURL.Scheme != "https" {
+ return false, nil, nil
+ }
+ if len(parsedURL.RawQuery) > 0 {
+ return false, nil, nil
+ }
+ if strings.Contains(parsedURL.Path, "..") {
+ return false, nil, nil
+ }
+ // if no domains, the matchedRE answer is authoritative
+ if len(client.AllowedRedirectDomains) < 1 {
+ return matchedRE, parsedURL, nil
+ }
+ if len(client.AllowedRedirectURLRE) < 1 {
+ matchedRE = true
+ }
+ matchedDomain := false
+ for _, domain := range client.AllowedRedirectDomains {
+ matched := strings.HasSuffix(parsedURL.Hostname(), domain)
+ if matched {
+ matchedDomain = true
+ break
+ }
+ }
+ return matchedDomain && matchedRE, parsedURL, nil
+}
+
+func (client *OpenIDConnectClientConfig) CorsOriginAllowed(origin string) (bool, error) {
+ parsedURL, err := url.Parse(origin)
+ if err != nil {
+ logger.Debugf(1, "user passed unparsable url as string err = %s", err)
+ return false, nil
+ }
+ if parsedURL.Scheme != "https" {
+ return false, nil
+ }
+ for _, domain := range client.AllowedRedirectDomains {
+ matched := strings.HasSuffix(parsedURL.Hostname(), domain)
+ if matched {
+ return true, nil
}
- return matchedDomain && matchedRE, nil
}
return false, nil
}
-func (state *RuntimeState) idpOpenIDCIsCorsOriginAllowed(origin string, clientId string) (bool, error) {
+func (client *OpenIDConnectClientConfig) RequestedAudienceIsAllowed(audience string) bool {
+ return client.AllowClientChosenAudiences
+}
+
+// This is weak we should be doing hashes
+func (client *OpenIDConnectClientConfig) ValidClientSecret(clientSecret string) bool {
+ return clientSecret == client.ClientSecret
+}
+
+func (client *OpenIDConnectClientConfig) ClientCanDoPKCEAuth() (bool, error) {
+ return client.ClientSecret == "", nil
+}
+
+func (state *RuntimeState) idpOpenIDCGenericIsCorsOriginAllowed(origin string) (bool, error) {
parsedURL, err := url.Parse(origin)
if err != nil {
logger.Debugf(1, "user passed unparsable url as string err = %s", err)
@@ -208,9 +239,6 @@ func (state *RuntimeState) idpOpenIDCIsCorsOriginAllowed(origin string, clientId
return false, nil
}
for _, client := range state.Config.OpenIDConnectIDP.Client {
- if clientId != "" && client.ClientID != clientId {
- continue
- }
for _, domain := range client.AllowedRedirectDomains {
matched := strings.HasSuffix(parsedURL.Hostname(), domain)
if matched {
@@ -295,13 +323,13 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
}
// We are now at exploration stage... and will require pre-authed clients.
- authUser, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
logger.Debugf(1, "%v", err)
return
}
- logger.Debugf(1, "AuthUser of idc auth: %s", authUser)
- w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser)
+ logger.Debugf(1, "AuthUser of idc auth: %s", authData.Username)
+ w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username)
// requst MUST be a GET or POST
if !(r.Method == "GET" || r.Method == "POST") {
state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Method for Auth Handler")
@@ -309,6 +337,15 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
}
err = r.ParseForm()
if err != nil {
+ if err.Error() == "invalid semicolon separator in query" {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid URL, contains semicolons")
+ return
+ }
+ if strings.Contains(err.Error(), "invalid") {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid URL")
+ return
+ }
+ logger.Printf("idpOpenIDCAuthorizationHandler Error parsing From err: %s", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
return
}
@@ -322,7 +359,7 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
clientID := r.Form.Get("client_id")
if clientID == "" {
- logger.Debugf(1, "empty client_id abourting")
+ logger.Debugf(1, "empty client_id aborting")
state.writeFailureResponse(w, r, http.StatusBadRequest, "Empty cleint_id for Auth Handler")
return
}
@@ -334,21 +371,31 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
}
}
if !validScope {
-
state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid scope value for Auth Handler")
return
}
- requestRedirectURLString := r.Form.Get("redirect_uri")
+ oidcClient, err := state.idpOpenIDCGetClientConfig(clientID)
+ if err != nil {
+ if err == ErrorIDPClientNotFound {
+ logger.Debugf(1, "Client Not Found clientID=%s", clientID)
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "ClientID uknown")
+ return
+ }
+ logger.Printf("%v", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ return
+ }
- ok, err := state.idpOpenIDCClientCanRedirect(clientID, requestRedirectURLString)
+ requestRedirectURLString := r.Form.Get("redirect_uri")
+ ok, parsedRedirectURL, err := oidcClient.CanRedirectToURL(requestRedirectURLString)
if err != nil {
logger.Printf("%v", err)
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
return
}
if !ok {
- state.writeFailureResponse(w, r, http.StatusBadRequest, "redirect string not valid or clientID uknown")
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "redirect string not valid")
return
}
@@ -395,6 +442,28 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
}
}
+
+ //For the initial version we will only allow a single extra audience
+
+ var accessAudience []string
+ requestedAudience := r.Form.Get("audience")
+ if requestedAudience != "" {
+ if !oidcClient.RequestedAudienceIsAllowed(requestedAudience) {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid audience")
+ return
+ }
+ validAudience, err := oidcClient.CorsOriginAllowed(requestedAudience)
+ if err != nil {
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if !validAudience {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid audience")
+ return
+ }
+ accessAudience = append(accessAudience, requestedAudience)
+ }
+
//Dont check for now
signerOptions := (&jose.SignerOptions{}).WithType("JWT")
//signerOptions.EmbedJWK = true
@@ -408,11 +477,12 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
codeToken.Scope = scope
codeToken.AuthExpiration = time.Now().Unix() + maxAgeSecondsAuthCookie
codeToken.Expiration = time.Now().Unix() + idpOpenIDCMaxAuthProcessMaxDurationSeconds
- codeToken.Username = authUser
+ codeToken.Username = authData.Username
codeToken.RedirectURI = requestRedirectURLString
codeToken.Type = "token_endpoint"
codeToken.ProtectedData = protectedCipherText
codeToken.ProtectedDataKey = protectedCipherTextKeys
+ codeToken.AccessAudience = accessAudience
codeToken.Nonce = r.Form.Get("nonce")
// Do nonce complexity check
if len(codeToken.Nonce) < 6 && len(codeToken.Nonce) != 0 {
@@ -428,8 +498,8 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter,
redirectPath := fmt.Sprintf("%s?code=%s&state=%s", requestRedirectURLString, raw, url.QueryEscape(r.Form.Get("state")))
logger.Debugf(3, "auth request is valid, redirect path=%s", redirectPath)
- logger.Printf("IDP: Successful oauth2 authorization: user=%s redirect url=%s", authUser, requestRedirectURLString)
- eventNotifier.PublishServiceProviderLoginEvent(requestRedirectURLString, authUser)
+ logger.Debugf(0, "IDP: Successful oauth2 authorization: user=%s redirect url=%s", authData.Username, parsedRedirectURL.Redacted())
+ eventNotifier.PublishServiceProviderLoginEvent(requestRedirectURLString, authData.Username)
http.Redirect(w, r, redirectPath, 302)
//logger.Printf("raw jwt =%v", raw)
}
@@ -444,38 +514,21 @@ type openIDConnectIDToken struct {
Nonce string `json:"nonce,omitempty"`
}
-type accessToken struct {
+type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
}
-type userInfoToken struct {
- Username string `json:"username"`
- Scope string `json:"scope"`
- Expiration int64 `json:"exp"`
- Type string `json:"type"`
-}
-
-func (state *RuntimeState) idpOpenIDCValidClientSecret(clientId string, clientSecret string) bool {
- for _, client := range state.Config.OpenIDConnectIDP.Client {
- if client.ClientID != clientId {
- continue
- }
- return clientSecret == client.ClientSecret
- }
- return false
-}
-
-func (state *RuntimeState) idpOpenIDCClientCanDoPKCEAuth(clientId string) (bool, error) {
- for _, client := range state.Config.OpenIDConnectIDP.Client {
- if client.ClientID != clientId {
- continue
- }
- return client.ClientSecret == "", nil
- }
- return false, nil
+type bearerAccessToken struct {
+ Issuer string `json:"iss"`
+ Audience []string `json:"aud,omitempty"`
+ Username string `json:"username"`
+ Scope string `json:"scope"`
+ Expiration int64 `json:"exp"`
+ IssuedAt int64 `json:"iat"`
+ Type string `json:"type"`
}
func (state *RuntimeState) idpOpenIDCValidCodeVerifier(clientId string, codeVerifier string, codeToken keymasterdCodeToken) bool {
@@ -522,7 +575,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http
return
}
if r.Form.Get("grant_type") != "authorization_code" {
- logger.Printf("invalid grant type='%s'", r.Form.Get("grant_type"))
+ logger.Debugf(1, "invalid grant type='%s'", url.QueryEscape(r.Form.Get("grant_type")))
state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid grant type")
return
}
@@ -604,9 +657,19 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http
pass = unescapedPass
}
}
+ oidcClient, err := state.idpOpenIDCGetClientConfig(clientID)
+ if err != nil {
+ if err == ErrorIDPClientNotFound {
+ state.writeFailureResponse(w, r, http.StatusBadRequest, "ClientID uknown")
+ return
+ }
+ logger.Printf("%v", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
+ return
+ }
valid := false
if len(codeVerifier) > 0 {
- canUserCodeVerifier, err := state.idpOpenIDCClientCanDoPKCEAuth(clientID)
+ canUserCodeVerifier, err := oidcClient.ClientCanDoPKCEAuth()
if err != nil {
logger.Printf("Error checking if client can do PKCE auth")
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
@@ -620,7 +683,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http
valid = state.idpOpenIDCValidCodeVerifier(clientID, codeVerifier, keymasterToken)
}
if !valid && len(pass) > 0 {
- valid = state.idpOpenIDCValidClientSecret(clientID, pass)
+ valid = oidcClient.ValidClientSecret(pass)
}
if !valid {
logger.Debugf(0, "Error invalid client secret or code verifier")
@@ -628,7 +691,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http
return
}
// if we have an origin it should be whitelisted
- originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(r.Header.Get("Origin"), clientID)
+ originIsValid, err := oidcClient.CorsOriginAllowed(r.Header.Get("Origin"))
if err != nil {
logger.Printf("Error checking Origin")
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
@@ -680,19 +743,28 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http
signedIdToken, err := jwt.Signed(signer).Claims(idToken).CompactSerialize()
if err != nil {
- panic(err)
+ log.Printf("error signing idToken in idpOpenIDCTokenHandler,: %s", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error")
+ return
}
logger.Debugf(2, "raw=%s", signedIdToken)
- userinfoToken := userInfoToken{Username: keymasterToken.Username, Scope: keymasterToken.Scope}
- userinfoToken.Expiration = idToken.Expiration
- userinfoToken.Type = "bearer"
- signedAccessToken, err := jwt.Signed(signer).Claims(userinfoToken).CompactSerialize()
+ accessToken := bearerAccessToken{Issuer: state.idpGetIssuer(),
+ Username: keymasterToken.Username, Scope: keymasterToken.Scope}
+ accessToken.Expiration = idToken.Expiration
+ accessToken.Type = "bearer"
+ accessToken.IssuedAt = time.Now().Unix()
+ if len(keymasterToken.AccessAudience) > 0 {
+ accessToken.Audience = append(keymasterToken.AccessAudience, state.idpGetIssuer()+idpOpenIDCUserinfoPath)
+ }
+ signedAccessToken, err := jwt.Signed(signer).Claims(accessToken).CompactSerialize()
if err != nil {
- panic(err)
+ log.Printf("error signing accessToken in idpOpenIDCTokenHandler: %s", err)
+ state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error")
+ return
}
// The access token will be yet another jwt.
- outToken := accessToken{
+ outToken := tokenResponse{
AccessToken: signedAccessToken,
TokenType: "Bearer",
ExpiresIn: int(idToken.Expiration - idToken.IssuedAt),
@@ -816,7 +888,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter,
state.writeFailureResponse(w, r, http.StatusBadRequest, "Options MUST contain origin")
return
}
- originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(origin, "")
+ originIsValid, err := state.idpOpenIDCGenericIsCorsOriginAllowed(origin)
if err != nil {
logger.Printf("Error checking Origin")
state.writeFailureResponse(w, r, http.StatusInternalServerError, "")
@@ -851,7 +923,6 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter,
}
logger.Debugf(1, "access_token='%s'", accessToken)
if accessToken == "" {
- logger.Printf("access_token='%s'", accessToken)
state.writeFailureResponse(w, r, http.StatusBadRequest,
"Missing access token")
return
@@ -864,7 +935,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter,
return
}
logger.Debugf(1, "tok=%+v", tok)
- parsedAccessToken := userInfoToken{}
+ parsedAccessToken := bearerAccessToken{}
if err := state.JWTClaims(tok, &parsedAccessToken); err != nil {
logger.Printf("err=%s", err)
state.writeFailureResponse(w, r, http.StatusBadRequest, "bad code")
@@ -874,13 +945,31 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter,
// Now we check for validity.
if parsedAccessToken.Expiration < time.Now().Unix() {
logger.Printf("expired token attempted to be used for bearer")
- state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
+ state.writeFailureResponse(w, r, http.StatusUnauthorized, "Expired Token")
return
}
if parsedAccessToken.Type != "bearer" {
- state.writeFailureResponse(w, r, http.StatusUnauthorized, "")
+ state.writeFailureResponse(w, r, http.StatusUnauthorized, "Wrong Token Type")
return
}
+ if parsedAccessToken.Issuer != state.idpGetIssuer() {
+ state.writeFailureResponse(w, r, http.StatusUnauthorized, "Invalid Token Issuer")
+ return
+ }
+ if len(parsedAccessToken.Audience) > 0 {
+ hasUserinfoAudience := false
+ userInfoURL := state.idpGetIssuer() + idpOpenIDCUserinfoPath
+ for _, audience := range parsedAccessToken.Audience {
+ if audience == userInfoURL {
+ hasUserinfoAudience = true
+ break
+ }
+ }
+ if !hasUserinfoAudience {
+ state.writeFailureResponse(w, r, http.StatusUnauthorized, "Invalid Audience in token")
+ return
+ }
+ }
// Get email from LDAP if available.
defaultEmailDomain := state.HostIdentity
if len(state.Config.OpenIDConnectIDP.DefaultEmailDomain) > 3 {
@@ -927,7 +1016,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter,
json.Indent(&out, b, "", "\t")
w.Header().Set("Content-Type", "application/json")
- originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(origin, "")
+ originIsValid, err := state.idpOpenIDCGenericIsCorsOriginAllowed(origin)
if err != nil {
logger.Printf("Error checking Origin, allowing to continue without origin header")
}
diff --git a/cmd/keymasterd/idp_oidc_test.go b/cmd/keymasterd/idp_oidc_test.go
index 000e8f00..1f0f78bd 100644
--- a/cmd/keymasterd/idp_oidc_test.go
+++ b/cmd/keymasterd/idp_oidc_test.go
@@ -1,6 +1,8 @@
package main
import (
+ "crypto/ed25519"
+ "crypto/rand"
"encoding/json"
stdlog "log"
"net/http"
@@ -69,6 +71,19 @@ func TestIDPOpenIDCJWKSHandler(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ // now add Ed25519 key to set of public keys
+ _, ed25519Priv, err := ed25519.GenerateKey(rand.Reader)
+ state.KeymasterPublicKeys = append(state.KeymasterPublicKeys, ed25519Priv.Public())
+ req2, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = checkRequestHandlerCode(req2, state.idpOpenIDCJWKSHandler, http.StatusOK)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // TODO: verify contents returned
+
}
func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) {
@@ -132,6 +147,7 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) {
rr, err := checkRequestHandlerCode(postReq, state.idpOpenIDCAuthorizationHandler, http.StatusFound)
if err != nil {
+ t.Logf("bad handler code %+v", rr)
t.Fatal(err)
}
t.Logf("%+v", rr)
@@ -174,7 +190,7 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- resultAccessToken := accessToken{}
+ resultAccessToken := tokenResponse{}
body := tokenRR.Result().Body
err = json.NewDecoder(body).Decode(&resultAccessToken)
if err != nil {
@@ -200,6 +216,67 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) {
}
+// Related to Issue 141: U2F redirect comes w/ semicolons
+func TestIDPOpenIDCAuthorizationInvalidURL(t *testing.T) {
+ badURLList := []string{
+ "/idp/oauth2/authorize?client_id=generc-purestorage&redirect_uri=https%3Acloudgate.example.com%2Foauth2%2Fredirectendpoint&response_type=code&scope=openid+mail+profile&state=eyJhbGciOiJIUzI1NiIsInR5cCI",
+ }
+
+ state, passwdFile, err := setupValidRuntimeStateSigner(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(passwdFile.Name()) // clean up
+ state.pendingOauth2 = make(map[string]pendingAuth2Request)
+ state.Config.Base.AllowedAuthBackendsForWebUI = []string{"password"}
+ state.signerPublicKeyToKeymasterKeys()
+ state.HostIdentity = "localhost"
+
+ valid_client_id := "valid_client_id"
+ valid_client_secret := "secret_password"
+ //valid_redirect_uri := "https://localhost:12345"
+ clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: valid_client_secret, AllowedRedirectURLRE: []string{"localhost"}}
+ state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig)
+
+ //url := idpOpenIDCAuthorizationPath
+ req, err := http.NewRequest("GET", idpOpenIDCAuthorizationPath, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ //First we do a simple request.. no auth should fail for now.. after build out it
+ // should be a redirect to the login page
+ _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusUnauthorized)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // now we add a cookie for auth
+ cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authCookie := http.Cookie{Name: authCookieName, Value: cookieVal}
+ req.AddCookie(&authCookie)
+ // and we retry with no params... it should fail again
+ _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusBadRequest)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, invalidURL := range badURLList {
+ req, err := http.NewRequest("GET", invalidURL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.AddCookie(&authCookie)
+ _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusBadRequest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+}
+
func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) {
state, passwdFile, err := setupValidRuntimeStateSigner(t)
if err != nil {
@@ -232,8 +309,12 @@ func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) {
}
testConfigClients := []string{"weakREWithDomains", "onlyWithDomains"}
for _, clientID := range testConfigClients {
+ client, err := state.idpOpenIDCGetClientConfig(clientID)
+ if err != nil {
+ t.Fatal(err)
+ }
for _, mustFailURL := range attackerTestURLS {
- resultMatch, err := state.idpOpenIDCClientCanRedirect(clientID, mustFailURL)
+ resultMatch, _, err := client.CanRedirectToURL(mustFailURL)
if err != nil {
t.Fatal(err)
}
@@ -242,13 +323,16 @@ func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) {
}
}
for _, mustPassURL := range expectedSuccessURLS {
- resultMatch, err := state.idpOpenIDCClientCanRedirect(clientID, mustPassURL)
+ resultMatch, parsedURL, err := client.CanRedirectToURL(mustPassURL)
if err != nil {
t.Fatal(err)
}
if resultMatch == false {
t.Fatal("should have allowed this url")
}
+ if parsedURL == nil {
+ t.Fatal("should have parsed this url")
+ }
}
}
}
@@ -290,8 +374,11 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) {
state.HostIdentity = "localhost"
valid_client_id := "valid_client_id"
valid_redirect_uri := "https://localhost:12345"
+ nonPKCEclientID := "nonPKCEClientId"
clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: "", AllowedRedirectURLRE: []string{"localhost"}}
+ clientConfig2 := OpenIDConnectClientConfig{ClientID: nonPKCEclientID, ClientSecret: "supersecret", AllowedRedirectURLRE: []string{"localhost"}}
state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig)
+ state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig2)
// now we add a cookie for auth
cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword)
if err != nil {
@@ -352,7 +439,7 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) {
}
// now a good verifier, but bad client_id
badVerifierTokenForm.Set("code_verifier", CodeVerifier.String())
- badVerifierTokenForm.Set("client_id", "invalidClientID")
+ badVerifierTokenForm.Set("client_id", nonPKCEclientID)
badVerifierTokenReq, err = http.NewRequest("POST", idpOpenIDCTokenPath, strings.NewReader(badVerifierTokenForm.Encode()))
if err != nil {
t.Fatal(err)
@@ -380,24 +467,155 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- resultAccessToken := accessToken{}
+ resultAccessToken := tokenResponse{}
+ body := tokenRR.Result().Body
+ err = json.NewDecoder(body).Decode(&resultAccessToken)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("resultAccessToken='%+v'", resultAccessToken)
+ //now the userinfo
+ userinfoForm := url.Values{}
+ userinfoForm.Add("access_token", resultAccessToken.AccessToken)
+ userinfoReq, err := http.NewRequest("POST", idpOpenIDCUserinfoPath, strings.NewReader(userinfoForm.Encode()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ userinfoReq.Header.Add("Content-Length", strconv.Itoa(len(userinfoForm.Encode())))
+ userinfoReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ _, err = checkRequestHandlerCode(userinfoReq, state.idpOpenIDCUserinfoHandler, http.StatusOK)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// we use a third party code generator to check some of the compatiblity issues
+func TestIDPOpenIDCPKCEFlowWithAudienceSuccess(t *testing.T) {
+ state, passwdFile, err := setupValidRuntimeStateSigner(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(passwdFile.Name()) // clean up
+ state.pendingOauth2 = make(map[string]pendingAuth2Request)
+ state.Config.Base.AllowedAuthBackendsForWebUI = []string{"password"}
+ state.signerPublicKeyToKeymasterKeys()
+ state.HostIdentity = "localhost"
+
+ valid_client_id := "valid_client_id"
+ //valid_client_secret := "secret_password"
+ valid_redirect_uri := "https://localhost:12345"
+ clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: "",
+ AllowClientChosenAudiences: true,
+ AllowedRedirectURLRE: []string{"localhost"}, AllowedRedirectDomains: []string{"localhost"},
+ }
+ state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig)
+
+ // now we add a cookie for auth
+ cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authCookie := http.Cookie{Name: authCookieName, Value: cookieVal}
+
+ //prepare code challenge
+ var CodeVerifier, _ = cv.CreateCodeVerifier()
+
+ // Create code_challenge with S256 method
+ codeChallenge := CodeVerifier.CodeChallengeS256()
+
+ // add the required params
+ form := url.Values{}
+ form.Add("scope", "openid")
+ form.Add("response_type", "code")
+ form.Add("client_id", valid_client_id)
+ form.Add("redirect_uri", valid_redirect_uri)
+ form.Add("nonce", "123456789")
+ form.Add("state", "this is my state")
+ form.Add("code_challenge_method", "S256")
+ form.Add("code_challenge", codeChallenge)
+ form.Add("audience", "https://api.localhost")
+
+ postReq, err := http.NewRequest("POST", idpOpenIDCAuthorizationPath, strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ postReq.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
+ postReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ postReq.AddCookie(&authCookie)
+ rr, err := checkRequestHandlerCode(postReq, state.idpOpenIDCAuthorizationHandler, http.StatusFound)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("%+v", rr)
+ locationText := rr.Header().Get("Location")
+ t.Logf("location=%s", locationText)
+ location, err := url.Parse(locationText)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rCode := location.Query().Get("code")
+ t.Logf("rCode=%s", rCode)
+
+ //now we do a token request
+ tokenForm := url.Values{}
+ tokenForm.Add("grant_type", "authorization_code")
+ tokenForm.Add("redirect_uri", valid_redirect_uri)
+ tokenForm.Add("code", rCode)
+ tokenForm.Add("client_id", valid_client_id)
+ tokenForm.Add("code_verifier", CodeVerifier.String())
+
+ tokenReq, err := http.NewRequest("POST", idpOpenIDCTokenPath, strings.NewReader(tokenForm.Encode()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tokenReq.Header.Add("Content-Length", strconv.Itoa(len(tokenForm.Encode())))
+ tokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ tokenRR, err := checkRequestHandlerCode(tokenReq, state.idpOpenIDCTokenHandler, http.StatusOK)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resultAccessToken := tokenResponse{}
body := tokenRR.Result().Body
err = json.NewDecoder(body).Decode(&resultAccessToken)
if err != nil {
t.Fatal(err)
}
t.Logf("resultAccessToken='%+v'", resultAccessToken)
+
+ // lets parse the access token to ensure the requested audience is there.
+ tok, err := jwt.ParseSigned(resultAccessToken.AccessToken)
+ if err != nil {
+ t.Fatal(err)
+ }
+ logger.Debugf(1, "tok=%+v", tok)
+ parsedAccessToken := bearerAccessToken{}
+ if err := state.JWTClaims(tok, &parsedAccessToken); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("parsedAccessToken Data ='%+v'", parsedAccessToken)
+ if len(parsedAccessToken.Audience) != 2 {
+ t.Fatalf("should have had only 2 audiences")
+ }
+ if parsedAccessToken.Audience[0] != "https://api.localhost" {
+ t.Fatalf("0th audience is not the one requested")
+ }
+
//now the userinfo
userinfoForm := url.Values{}
userinfoForm.Add("access_token", resultAccessToken.AccessToken)
+
userinfoReq, err := http.NewRequest("POST", idpOpenIDCUserinfoPath, strings.NewReader(userinfoForm.Encode()))
if err != nil {
t.Fatal(err)
}
userinfoReq.Header.Add("Content-Length", strconv.Itoa(len(userinfoForm.Encode())))
userinfoReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
_, err = checkRequestHandlerCode(userinfoReq, state.idpOpenIDCUserinfoHandler, http.StatusOK)
if err != nil {
t.Fatal(err)
}
+
}
diff --git a/cmd/keymasterd/jwt.go b/cmd/keymasterd/jwt.go
index 9c6299d9..3474aaa9 100644
--- a/cmd/keymasterd/jwt.go
+++ b/cmd/keymasterd/jwt.go
@@ -46,7 +46,8 @@ func (state *RuntimeState) JWTClaims(t *jwt.JSONWebToken, dest ...interface{}) (
return err
}
-func (state *RuntimeState) genNewSerializedAuthJWT(username string, authLevel int) (string, error) {
+func (state *RuntimeState) genNewSerializedAuthJWT(username string,
+ authLevel int, durationSeconds int64) (string, error) {
signerOptions := (&jose.SignerOptions{}).WithType("JWT")
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: state.Signer}, signerOptions)
if err != nil {
@@ -57,12 +58,17 @@ func (state *RuntimeState) genNewSerializedAuthJWT(username string, authLevel in
Audience: []string{issuer}, AuthType: authLevel, TokenType: "keymaster_auth"}
authToken.NotBefore = time.Now().Unix()
authToken.IssuedAt = authToken.NotBefore
- authToken.Expiration = authToken.IssuedAt + maxAgeSecondsAuthCookie // TODO seek the actual duration
-
+ authToken.Expiration = authToken.IssuedAt + durationSeconds
return jwt.Signed(signer).Claims(authToken).CompactSerialize()
}
-func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) (rvalue authInfo, err error) {
+func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) (
+ rvalue authInfo, err error) {
+ return state.getAuthInfoFromJWT(serializedToken, "keymaster_auth")
+}
+
+func (state *RuntimeState) getAuthInfoFromJWT(serializedToken,
+ tokenType string) (rvalue authInfo, err error) {
tok, err := jwt.ParseSigned(serializedToken)
if err != nil {
return rvalue, err
@@ -74,16 +80,16 @@ func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) (rvalu
}
//At this stage is now crypto verified, now is time to verify sane values
issuer := state.idpGetIssuer()
- if inboundJWT.Issuer != issuer || inboundJWT.TokenType != "keymaster_auth" ||
+ if inboundJWT.Issuer != issuer || inboundJWT.TokenType != tokenType ||
len(inboundJWT.Audience) < 1 || inboundJWT.Audience[0] != issuer ||
inboundJWT.NotBefore > time.Now().Unix() {
err = errors.New("invalid JWT values")
return rvalue, err
}
-
- rvalue.Username = inboundJWT.Subject
rvalue.AuthType = inboundJWT.AuthType
rvalue.ExpiresAt = time.Unix(inboundJWT.Expiration, 0)
+ rvalue.IssuedAt = time.Unix(inboundJWT.IssuedAt, 0)
+ rvalue.Username = inboundJWT.Subject
return rvalue, nil
}
diff --git a/cmd/keymasterd/logFilter.go b/cmd/keymasterd/logFilter.go
index d3a2e1c1..ec4c2cc3 100644
--- a/cmd/keymasterd/logFilter.go
+++ b/cmd/keymasterd/logFilter.go
@@ -33,7 +33,7 @@ func (state *RuntimeState) sendFailureToClientIfNotAdminUserOrCA(
state.logger.Debugf(4, "request is TLS %+v", r.TLS)
if len(r.TLS.VerifiedChains) > 0 {
state.logger.Debugf(4, "%+v", r.TLS.VerifiedChains[0][0].Subject)
- username, err := state.getUsernameIfKeymasterSigned(
+ username, _, err := state.getUsernameIfKeymasterSigned(
r.TLS.VerifiedChains)
if err != nil {
state.logger.Println(err)
@@ -46,11 +46,11 @@ func (state *RuntimeState) sendFailureToClientIfNotAdminUserOrCA(
}
return false
}
- username, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
+ authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel())
if err != nil {
return true
}
- if !state.IsAdminUser(username) {
+ if !state.IsAdminUser(authData.Username) {
http.Error(w, "Not an admin user", http.StatusUnauthorized)
return true
}
diff --git a/cmd/keymasterd/main_test.go b/cmd/keymasterd/main_test.go
index 3e05ba69..cc590e0a 100644
--- a/cmd/keymasterd/main_test.go
+++ b/cmd/keymasterd/main_test.go
@@ -17,10 +17,13 @@ import (
"strings"
"testing"
+ "golang.org/x/time/rate"
+
"github.com/Cloud-Foundations/Dominator/lib/log/debuglogger"
"github.com/Cloud-Foundations/golib/pkg/log/testlogger"
"github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier"
"github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter"
+ "github.com/Cloud-Foundations/keymaster/lib/pwauth/htpassword"
"github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto"
)
@@ -167,7 +170,11 @@ func setupPasswdFile() (f *os.File, err error) {
func setupValidRuntimeStateSigner(t *testing.T) (
*RuntimeState, *os.File, error) {
- state := RuntimeState{logger: testlogger.New(t)}
+ logger := testlogger.New(t)
+ state := RuntimeState{
+ passwordAttemptGlobalLimiter: rate.NewLimiter(10.0, 100),
+ logger: logger,
+ }
//load signer
signer, err := getSignerFromPEMBytes([]byte(testSignerPrivateKey))
if err != nil {
@@ -187,7 +194,10 @@ func setupValidRuntimeStateSigner(t *testing.T) (
if err != nil {
return nil, nil, err
}
- state.Config.Base.HtpasswdFilename = passwdFile.Name()
+ state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger)
+ if err != nil {
+ return nil, nil, err
+ }
state.totpLocalRateLimit = make(map[string]totpRateLimitInfo)
return &state, passwdFile, nil
@@ -205,7 +215,8 @@ func TestSuccessFullSigningSSH(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest)
+ _, err = checkRequestHandlerCode(req, state.certGenHandler,
+ http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
}
@@ -244,7 +255,8 @@ func TestSuccessFullSigningX509(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest)
+ _, err = checkRequestHandlerCode(req, state.certGenHandler,
+ http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
}
@@ -282,7 +294,8 @@ func TestSuccessFullSigningX509BadLDAPNoGroups(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest)
+ _, err = checkRequestHandlerCode(req, state.certGenHandler,
+ http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
}
@@ -320,7 +333,8 @@ func TestFailFullSigningX509GroupsBadLDAPNoGroups(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest)
+ _, err = checkRequestHandlerCode(req, state.certGenHandler,
+ http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
}
@@ -357,7 +371,8 @@ func TestFailCertgenDurationTooLong(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest)
+ _, err = checkRequestHandlerCode(req, state.certGenHandler,
+ http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
}
@@ -435,7 +450,7 @@ func TestFailSingingExpiredCookie(t *testing.T) {
// THis tests needs to be rewritten to have and expired token... need to figure out
// the best way to do this.
/*
- state.authCookie[cookieVal] = authInfo{Username: "username", AuthType: AuthTypeU2F, ExpiresAt: time.Now().Add(-120 * time.Second)}
+ state.authCookie[cookieVal] = authInfo{IssuedAt: time.Now(), Username: "username", AuthType: AuthTypeU2F, ExpiresAt: time.Now().Add(-120 * time.Second)}
_, err = checkRequestHandlerCode(cookieReq, state.certGenHandler, http.StatusUnauthorized)
if err != nil {
t.Fatal(err)
@@ -569,7 +584,10 @@ func TestLoginAPIBasicAuth(t *testing.T) {
t.Fatal(err)
}
defer os.Remove(passwdFile.Name()) // clean up
- state.Config.Base.HtpasswdFilename = passwdFile.Name()
+ state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
err = initDB(state)
if err != nil {
t.Fatal(err)
@@ -623,7 +641,10 @@ func TestLoginAPIFormAuth(t *testing.T) {
t.Fatal(err)
}
defer os.Remove(passwdFile.Name()) // clean up
- state.Config.Base.HtpasswdFilename = passwdFile.Name()
+ state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
err = initDB(state)
if err != nil {
t.Fatal(err)
diff --git a/cmd/keymasterd/static_files/jquery-3.5.1.min.js b/cmd/keymasterd/static_files/jquery-3.5.1.min.js
deleted file mode 100644
index b0614034..00000000
--- a/cmd/keymasterd/static_files/jquery-3.5.1.min.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */
-!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,""],col:[2,""],tr:[2,""],td:[3,""],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.cssHas=ce(function(){try{return C.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),d.cssHas||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType&&e.documentElement||e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,""],col:[2,""],tr:[2,""],td:[3,""],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 c.charCodeAt(0));
+}
+
+// ArrayBuffer to URLBase64
+function bufferEncode(value) {
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");;
+}
+
+
+
+function webAuthnRegisterUser() {
+
+ var username = document.getElementById('username').textContent;
+ $.get(
+ '/webauthn/RegisterRequest/' + username,
+ null,
+ function (data) {
+ return data
+ },
+ 'json')
+ .then((credentialCreationOptions) => {
+ // TODO
+ //alert(credentialCreationOptions);
+ console.log(credentialCreationOptions);
+ credentialCreationOptions.publicKey.challenge = bufferDecode(credentialCreationOptions.publicKey.challenge);
+ credentialCreationOptions.publicKey.user.id = bufferDecode(credentialCreationOptions.publicKey.user.id);
+ credentialCreationOptions.publicKey.authenticatorSelection.userVerification="discouraged";
+ console.log(credentialCreationOptions);
+ return navigator.credentials.create({
+ publicKey: credentialCreationOptions.publicKey
+ })
+ })
+ .then((credential) => {
+ // TODO
+ //alert("starting then credentials");
+ let attestationObject = credential.response.attestationObject;
+ let clientDataJSON = credential.response.clientDataJSON;
+ let rawId = credential.rawId;
+
+ $.post(
+ '/webauthn/RegisterFinish/' + username,
+ JSON.stringify({
+ id: credential.id,
+ rawId: bufferEncode(rawId),
+ type: credential.type,
+ response: {
+ attestationObject: bufferEncode(attestationObject),
+ clientDataJSON: bufferEncode(clientDataJSON),
+ },
+ }),
+ function (data) {
+ return data
+ },
+ 'json')
+
+ })
+ .then((success) => {
+ alert("successfully registered " + username + "!")
+ return
+ })
+ .catch((error) => {
+ console.log(error)
+ alert("failed to register " + username)
+ });
+}
+
+function webAuthnAuthenticateUser() {
+
+ var username = document.getElementById('username').textContent;
+
+ $.get(
+ '/webauthn/AuthBegin/' + username,
+ null,
+ function (data) {
+ return data
+ },
+ 'json')
+ .then((credentialRequestOptions) => {
+
+ credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge);
+ credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) {
+ listItem.id = bufferDecode(listItem.id)
+ });
+ //credentialRequestOptions.publicKey.authenticatorSelection.userVerification="discouraged";
+
+ return navigator.credentials.get({
+ publicKey: credentialRequestOptions.publicKey
+ })
+ })
+ .then((assertion) => {
+
+ let authData = assertion.response.authenticatorData;
+ let clientDataJSON = assertion.response.clientDataJSON;
+ let rawId = assertion.rawId;
+ let sig = assertion.response.signature;
+ let userHandle = assertion.response.userHandle;
+
+ $.post(
+ '/webauthn/AuthFinish/' + username,
+ JSON.stringify({
+ id: assertion.id,
+ rawId: bufferEncode(rawId),
+ type: assertion.type,
+ response: {
+ authenticatorData: bufferEncode(authData),
+ clientDataJSON: bufferEncode(clientDataJSON),
+ signature: bufferEncode(sig),
+ userHandle: bufferEncode(userHandle),
+ },
+ }),
+ function (data) {
+ console.log("Authnenticated: " + data);
+ alert("successfully authenticated " + username + "!");
+ return data
+ },
+ 'json')
+ })
+ .then((success) => {
+ console.log("button pressed")
+ return
+ })
+ .catch((error) => {
+ console.log(error)
+ alert("failed to authenticate " + username)
+ });
+}
+
+
+
+document.addEventListener('DOMContentLoaded', function () {
+ document.getElementById('webauthn_auth_button').addEventListener('click', webAuthnAuthenticateUser);
+ document.getElementById('webauthn_register_button').addEventListener('click', webAuthnRegisterUser);
+ // main();
+});
diff --git a/cmd/keymasterd/static_files/webui-2fa-okta-push.js b/cmd/keymasterd/static_files/webui-2fa-okta-push.js
index 3a56efa8..be32c2cb 100644
--- a/cmd/keymasterd/static_files/webui-2fa-okta-push.js
+++ b/cmd/keymasterd/static_files/webui-2fa-okta-push.js
@@ -3,7 +3,7 @@
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
- var destination = document.getElementById("okta_login_destination").innerHTML;
+ var destination = document.getElementById("login_destination_input").getAttribute("value");
window.location.href = destination;
}
};
@@ -29,9 +29,7 @@
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
- //var destination = document.getElementById("vip_login_destination").innerHTML;
- //window.location.href = destination;
- cosole.log("success okta push start")
+ console.log("success okta push start")
}
};
xhr.open("GET", "/api/v0/oktaPushStart", true);
diff --git a/cmd/keymasterd/static_files/webui-2fa-symc-vip.js b/cmd/keymasterd/static_files/webui-2fa-symc-vip.js
index 8224c003..942de5b2 100644
--- a/cmd/keymasterd/static_files/webui-2fa-symc-vip.js
+++ b/cmd/keymasterd/static_files/webui-2fa-symc-vip.js
@@ -3,7 +3,7 @@
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
- var destination = document.getElementById("vip_login_destination").innerHTML;
+ var destination = document.getElementById("login_destination_input").getAttribute("value");
window.location.href = destination;
}
};
@@ -28,9 +28,7 @@
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
- //var destination = document.getElementById("vip_login_destination").innerHTML;
- //window.location.href = destination;
- cosole.log("success vip push start")
+ console.log("success vip push start")
}
};
xhr.open("GET", "/api/v0/vipPushStart", true);
diff --git a/cmd/keymasterd/static_files/webui-2fa-u2f.js b/cmd/keymasterd/static_files/webui-2fa-u2f.js
index edddbe00..ef9aed1c 100644
--- a/cmd/keymasterd/static_files/webui-2fa-u2f.js
+++ b/cmd/keymasterd/static_files/webui-2fa-u2f.js
@@ -1,3 +1,16 @@
+// Base64 to ArrayBuffer
+function bufferDecode(value) {
+ return Uint8Array.from(atob(value), c => c.charCodeAt(0));
+}
+
+// ArrayBuffer to URLBase64
+function bufferEncode(value) {
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");;
+}
+
function serverError(data) {
console.log(data);
alert('Server error code ' + data.status + ': ' + data.responseText);
@@ -45,7 +58,7 @@ function checkError(resp) {
}
$.post('/u2f/SignResponse', JSON.stringify(resp)).done(function() {
//alert('Success');
- var destination = document.getElementById("u2f_login_destination").innerHTML;
+ var destination = document.getElementById("login_destination_input").getAttribute("value");
window.location.href = destination;
}).fail(serverError);
}
@@ -57,7 +70,66 @@ function checkError(resp) {
}).fail(serverError);
}
+
+
+function webAuthnAuthenticateUser2() {
+ console.log("top of webAuthnAuthenticateUser2");
+ $.get(
+ '/webauthn/AuthBegin/',
+ null,
+ function (data) {
+ return data
+ },
+ 'json')
+ .then((credentialRequestOptions) => {
+ credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge);
+ credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) {
+ listItem.id = bufferDecode(listItem.id)
+ });
+
+ return navigator.credentials.get({
+ publicKey: credentialRequestOptions.publicKey
+ })
+ })
+ .then((assertion) => {
+
+ let authData = assertion.response.authenticatorData;
+ let clientDataJSON = assertion.response.clientDataJSON;
+ let rawId = assertion.rawId;
+ let sig = assertion.response.signature;
+ let userHandle = assertion.response.userHandle;
+ $.post(
+ '/webauthn/AuthFinish/',
+ JSON.stringify({
+ id: assertion.id,
+ rawId: bufferEncode(rawId),
+ type: assertion.type,
+ response: {
+ authenticatorData: bufferEncode(authData),
+ clientDataJSON: bufferEncode(clientDataJSON),
+ signature: bufferEncode(sig),
+ userHandle: bufferEncode(userHandle),
+ },
+ }),
+ function (data) {
+ console.log("on post with some data " + data)
+ var destination = document.getElementById("login_destination_input").getAttribute("value");
+ window.location.href = destination;
+ return data;
+ },
+ 'json')
+ })
+ .then((success) => {
+ console.log("successfully pressed button");
+ //hideAllU2FElements();
+ })
+ .catch((error) => {
+ console.log(error)
+ alert("failed to authenticate ")
+ });
+}
+
+
document.addEventListener('DOMContentLoaded', function () {
- //document.getElementById('auth_button').addEventListener('click', sign);
- sign();
+ webAuthnAuthenticateUser2();
});
diff --git a/cmd/keymasterd/storage.go b/cmd/keymasterd/storage.go
index b36c4028..191a488c 100644
--- a/cmd/keymasterd/storage.go
+++ b/cmd/keymasterd/storage.go
@@ -505,7 +505,8 @@ func (state *RuntimeState) LoadUserProfile(username string) (
decoder := gob.NewDecoder(gobReader)
err = decoder.Decode(&defaultProfile)
if err != nil {
- return nil, false, fromCache, err
+ return nil, false, fromCache,
+ fmt.Errorf("error decoding user profile: %s", err)
}
logger.Debugf(1, "loaded profile=%+v", defaultProfile)
return &defaultProfile, true, fromCache, nil
diff --git a/cmd/keymasterd/storage_test.go b/cmd/keymasterd/storage_test.go
index 0794565a..7b16b543 100644
--- a/cmd/keymasterd/storage_test.go
+++ b/cmd/keymasterd/storage_test.go
@@ -6,6 +6,8 @@ import (
"os"
"testing"
+ "golang.org/x/time/rate"
+
"github.com/Cloud-Foundations/Dominator/lib/log/debuglogger"
"github.com/Cloud-Foundations/golib/pkg/log/testlogger"
"github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier"
@@ -22,7 +24,10 @@ func newTestingState(t *testing.T) (*RuntimeState, string, error) {
if err != nil {
return nil, "", err
}
- state := &RuntimeState{logger: testlogger.New(t)}
+ state := &RuntimeState{
+ passwordAttemptGlobalLimiter: rate.NewLimiter(10.0, 100),
+ logger: testlogger.New(t),
+ }
state.Config.Base.DataDirectory = tmpdir
return state, tmpdir, nil
}
diff --git a/cmd/keymasterd/templateData.go b/cmd/keymasterd/templateData.go
index 934448ca..07e42571 100644
--- a/cmd/keymasterd/templateData.go
+++ b/cmd/keymasterd/templateData.go
@@ -7,12 +7,25 @@ import (
const headerTemplateText = `
{{define "header"}}
+{{if .SessionExpires}}
+
+{{end}}
@@ -25,7 +38,7 @@ const footerTemplateText = `
@@ -33,16 +46,17 @@ Copright 2017-2019 Symantec Corporation; 2019-2020 Cloud-Foundations.org.
`
type loginPageTemplateData struct {
- Title string
- AuthUsername string
- JSSources []string
- ShowOauth2 bool
- HideStdLogin bool
- LoginDestination string
- ErrorMessage string
+ Title string
+ AuthUsername string
+ SessionExpires int64
+ DefaultUsername string
+ JSSources []string
+ ShowBasicAuth bool
+ ShowOauth2 bool
+ LoginDestinationInput template.HTML
+ ErrorMessage string
}
-//Should be a template
const loginFormText = `
{{define "loginPage"}}
@@ -64,18 +78,28 @@ const loginFormText = `
{{end}}
{{if .ShowOauth2}}
- Oauth2 Login
+
- {{end}}
- {{if not .HideStdLogin}}
+ {{end}}
+ {{if .ShowBasicAuth}}
{{template "login_pre_password" .}}
- {{end}}
+ {{end}}
{{template "login_form_footer" .}}
{{template "footer" . }}
@@ -86,15 +110,16 @@ const loginFormText = `
`
type secondFactorAuthTemplateData struct {
- Title string
- AuthUsername string
- JSSources []string
- ShowBootstrapOTP bool
- ShowVIP bool
- ShowU2F bool
- ShowTOTP bool
- ShowOktaOTP bool
- LoginDestination string
+ Title string
+ AuthUsername string
+ SessionExpires int64
+ JSSources []string
+ ShowBootstrapOTP bool
+ ShowVIP bool
+ ShowU2F bool
+ ShowTOTP bool
+ ShowOktaOTP bool
+ LoginDestinationInput template.HTML
}
const secondFactorAuthFormText = `
@@ -119,21 +144,19 @@ const secondFactorAuthFormText = `
Keymaster second factor authentication
{{if .ShowBootstrapOTP}}
-
{{.LoginDestination}}
{{end}}
{{if .ShowVIP}}
-
{{.LoginDestination}}
@@ -150,7 +173,6 @@ const secondFactorAuthFormText = `
{{if .ShowU2F}}
-
{{.LoginDestination}}
Authenticate by touching a blinking registered U2F device (insert if not inserted yet)
{{if .ShowVIP}}
@@ -167,14 +189,13 @@ const secondFactorAuthFormText = `
{{end}}
{{if .ShowOktaOTP}}
-
{{.LoginDestination}}
@@ -192,6 +213,7 @@ const secondFactorAuthFormText = `
If you have login issues, you can also
+ {{.LoginDestinationInput}}
@@ -203,10 +225,11 @@ const secondFactorAuthFormText = `
`
type usersPageTemplateData struct {
- Title string
- AuthUsername string
- JSSources []string
- Users []string
+ Title string
+ AuthUsername string
+ SessionExpires int64
+ JSSources []string
+ Users []string
}
const usersHTML = `
@@ -279,17 +302,18 @@ type profilePageTemplateData struct {
Title string
AuthUsername string
Username string
+ SessionExpires int64
JSSources []string
BootstrapOTP *bootstrapOtpTemplateData
ShowU2F bool
ShowTOTP bool
ReadOnlyMsg string
UsersLink bool
+ ShowLegacyRegister bool
RegisteredU2FToken []registeredU2FTokenDisplayInfo
RegisteredTOTPDevice []registeredTOTPTDeviceDisplayInfo
}
-//{{ .Date | formatAsDate}} {{ printf "%-20s" .Description }} {{.AmountInCents | formatAsDollars -}}
const profileHTML = `
{{define "userProfilePage"}}
@@ -323,23 +347,27 @@ const profileHTML = `
Users
{{end}}
-
{{if .BootstrapOTP}}
+
Bootstrap OTP fingerprint:
{{printf "%x" .BootstrapOTP.Fingerprint}}
expires at: {{.BootstrapOTP.ExpiresAt}}
+
{{end}}
U2F
{{if .ShowU2F}}
{{if not .ReadOnlyMsg}}
+ {{if .ShowLegacyRegister}}
-
- Register token
+ Register token (Legacy)
Please Touch the blinking device to register(insert if not inserted yet)
{{end}}
- - Authenticate
-
Please Touch the blinking device to authenticate(insert if not inserted yet)
+ {{end}}
+ - Authenticate
+
+ - Register U2F device
{{else}}
Your browser does not support U2F. However you can still Enable/Disable/Delete U2F tokens
@@ -441,6 +469,7 @@ const profileHTML = `
type newTOTPPageTemplateData struct {
Title string
AuthUsername string
+ SessionExpires int64
JSSources []string
ErrorMessage string
TOTPBase64Image template.HTML
@@ -500,6 +529,7 @@ const newTOTPHTML = `
type newBootstrapOTPPPageTemplateData struct {
Title string
AuthUsername string
+ SessionExpires int64
JSSources []string
ErrorMessage string
Username string
@@ -551,3 +581,57 @@ const newBootstrapOTPPHTML = `