Commit 58b44b15 authored by ale's avatar ale

Add support for unlocking a remote user key store

parent 17302a9d
Pipeline #708 passed with stage
in 33 seconds
......@@ -6,8 +6,10 @@ import (
"regexp"
"time"
"git.autistici.org/id/go-sso/server/device"
ksclient "git.autistici.org/id/keystore/client"
"github.com/gorilla/securecookie"
"git.autistici.org/id/go-sso/server/device"
)
// Config data for the SSO service.
......@@ -27,11 +29,12 @@ type Config struct {
TTLSeconds int `yaml:"ttl"`
rx *regexp.Regexp
} `yaml:"service_ttls"`
AuthSessionLifetimeSeconds int `yaml:"auth_session_lifetime"`
SessionSecrets []string `yaml:"session_secrets"`
CSRFSecret string `yaml:"csrf_secret"`
AuthService string `yaml:"auth_service"`
DeviceManager *device.Config `yaml:"device_manager"`
AuthSessionLifetimeSeconds int `yaml:"auth_session_lifetime"`
SessionSecrets []string `yaml:"session_secrets"`
CSRFSecret string `yaml:"csrf_secret"`
AuthService string `yaml:"auth_service"`
DeviceManager *device.Config `yaml:"device_manager"`
KeyStore *ksclient.Config `yaml:"keystore"`
allowedServicesRx []*regexp.Regexp
}
......
......@@ -21,6 +21,8 @@ import (
"git.autistici.org/id/auth"
authclient "git.autistici.org/id/auth/client"
ksclient "git.autistici.org/id/keystore/client"
"git.autistici.org/id/go-sso/httputil"
"git.autistici.org/id/go-sso/server/device"
)
......@@ -84,6 +86,7 @@ type Server struct {
authSessionLifetime time.Duration
loginHandler *loginHandler
loginService *LoginService
keystore *ksclient.Client
csrfSecret []byte
tpl *template.Template
}
......@@ -106,6 +109,7 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi
MaxAge: 0,
Path: "/",
}
s := &Server{
authSessionLifetime: defaultAuthSessionLifetime,
authSessionStore: store,
......@@ -119,6 +123,14 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi
s.authSessionLifetime = time.Duration(config.AuthSessionLifetimeSeconds) * time.Second
}
if config.KeyStore != nil {
ks, err := ksclient.New(config.KeyStore)
if err != nil {
return nil, err
}
s.keystore = ks
}
devMgr, err := device.New(config.DeviceManager)
if err != nil {
return nil, err
......@@ -128,7 +140,20 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi
return s, nil
}
func (h *Server) loginCallback(w http.ResponseWriter, req *http.Request, username string, userinfo *auth.UserInfo) error {
func (h *Server) loginCallback(w http.ResponseWriter, req *http.Request, username, password string, userinfo *auth.UserInfo) error {
log.Printf("successful login for user %s", username)
// Open the keystore for this user with the password used to
// authenticate. Set the TTL to the duration of the
// authenticated session.
if h.keystore != nil {
if err := h.keystore.Open(req.Context(), username, password, int(h.authSessionLifetime.Seconds())); err != nil {
log.Printf("failed to unlock keystore for user %s: %v", username, err)
return err
}
}
// Create cookie-based session for the authenticated user.
session := newAuthSession(h.authSessionLifetime, username, userinfo)
httpSession, _ := h.authSessionStore.Get(req, authSessionKey)
httpSession.Values["auth"] = session
......@@ -229,6 +254,13 @@ func (h *Server) handleLogout(w http.ResponseWriter, req *http.Request, session
httpSession, _ := h.authSessionStore.Get(req, authSessionKey)
httpSession.Options.MaxAge = -1
_ = httpSession.Save(req, w)
// Close the keystore.
if h.keystore != nil {
if err := h.keystore.Close(req.Context(), session.Username); err != nil {
log.Printf("failed to wipe keystore for user %s: %v", session.Username, err)
}
}
}
h.tpl.ExecuteTemplate(w, "logout.html", data)
......
......@@ -3,7 +3,9 @@ package server
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"io"
"io/ioutil"
"net/http"
"net/http/cookiejar"
......@@ -15,6 +17,7 @@ import (
"testing"
"git.autistici.org/id/auth"
"git.autistici.org/id/keystore"
)
type fakeAuthClient struct{}
......@@ -36,10 +39,7 @@ func (c *fakeAuthClient) Authenticate(_ context.Context, req *auth.Request) (*au
return &auth.Response{Status: auth.StatusError}, nil
}
func startTestHTTPServer(t testing.TB) (string, *httptest.Server) {
tmpdir, _ := ioutil.TempDir("", "")
config := testConfig(t, tmpdir)
func createTestHTTPServer(t testing.TB, config *Config) *httptest.Server {
svc, err := NewLoginService(config)
if err != nil {
t.Fatal("NewLoginService():", err)
......@@ -50,8 +50,21 @@ func startTestHTTPServer(t testing.TB) (string, *httptest.Server) {
t.Fatal("New():", err)
}
httpSrv := httptest.NewTLSServer(srv.Handler())
return tmpdir, httpSrv
return httptest.NewTLSServer(srv.Handler())
}
func startTestHTTPServer(t testing.TB) (string, *httptest.Server) {
tmpdir, _ := ioutil.TempDir("", "")
config := testConfig(t, tmpdir, "")
return tmpdir, createTestHTTPServer(t, config)
}
func startTestHTTPServerWithKeyStore(t testing.TB) (string, *httptest.Server) {
ks := createFakeKeyStore(t, "testuser", "password")
tmpdir, _ := ioutil.TempDir("", "")
config := testConfig(t, tmpdir, ks.URL)
return tmpdir, createTestHTTPServer(t, config)
}
func newTestHTTPClient() *http.Client {
......@@ -209,3 +222,49 @@ func TestHTTP_LoginOTP(t *testing.T) {
v.Set("otp", "123456")
doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
}
func createFakeKeyStore(t testing.TB, username, password string) *httptest.Server {
h := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/api/open" {
http.NotFound(w, req)
return
}
var openReq keystore.OpenRequest
if err := json.NewDecoder(req.Body).Decode(&openReq); err != nil {
t.Errorf("bad JSON body: %v", err)
return
}
if openReq.Username != username {
t.Errorf("bad username in keystore Open request: expected %s, got %s", username, openReq.Username)
}
if openReq.Password != password {
t.Errorf("bad password in keystore Open request: expected %s, got %s", password, openReq.Password)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, "{}")
})
return httptest.NewServer(h)
}
func TestHTTP_LoginWithKeyStore(t *testing.T) {
tmpdir, httpSrv := startTestHTTPServerWithKeyStore(t)
defer os.RemoveAll(tmpdir)
defer httpSrv.Close()
c := newTestHTTPClient()
// Simulate an authorization request from a service, expect to
// see the login page.
v := make(url.Values)
v.Set("s", "service.example.com/")
v.Set("d", "https://service.example.com/admin/")
v.Set("n", "averysecretnonce")
doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
// Attempt to login by submitting the form. We expect the
// result to be a 302 redirect to the target service.
v = make(url.Values)
v.Set("username", "testuser")
v.Set("password", "password")
doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
}
......@@ -66,7 +66,7 @@ func init() {
gob.Register(&loginSession{})
}
type loginCallbackFunc func(http.ResponseWriter, *http.Request, string, *auth.UserInfo) error
type loginCallbackFunc func(http.ResponseWriter, *http.Request, string, string, *auth.UserInfo) error
type loginHandler struct {
authClient authclient.Client
......@@ -132,7 +132,7 @@ func (l *loginHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Successful login. Delete the login session.
httpSession.Options.MaxAge = -1
_ = httpSession.Save(req, w)
if err := l.loginCallback(w, req, session.Username, session.UserInfo); err != nil {
if err := l.loginCallback(w, req, session.Username, session.Password, session.UserInfo); err != nil {
log.Printf("login callback error: %v: user=%s", err, session.Username)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
......@@ -191,13 +191,15 @@ func (l *loginHandler) handlePassword(w http.ResponseWriter, req *http.Request,
if err != nil {
return loginStateNone, nil, err
}
// Save username / password for later in case of
// successful (or partially succesful) result.
switch resp.Status {
case auth.StatusOK:
session.Username = username
session.Password = password
session.UserInfo = resp.UserInfo
return loginStateSuccess, nil, nil
case auth.StatusInsufficientCredentials:
// Save username / password for later.
session.Username = username
session.Password = password
// If there is a U2F challenge in the auth
......
......@@ -24,14 +24,15 @@ func loadConfig(path string) (*Config, error) {
return &config, nil
}
func testConfig(t testing.TB, tmpdir string) *Config {
func testConfig(t testing.TB, tmpdir, keystoreURL string) *Config {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
ioutil.WriteFile(filepath.Join(tmpdir, "secret"), priv, 0600)
ioutil.WriteFile(filepath.Join(tmpdir, "public"), pub, 0600)
ioutil.WriteFile(filepath.Join(tmpdir, "config"), []byte(fmt.Sprintf(`---
cfgstr := fmt.Sprintf(`---
secret_key_file: %s
public_key_file: %s
domain: example.com
......@@ -41,7 +42,14 @@ service_ttls:
- regexp: ".*"
ttl: 60
auth_service: login
`, filepath.Join(tmpdir, "secret"), filepath.Join(tmpdir, "public"))), 0600)
`, filepath.Join(tmpdir, "secret"), filepath.Join(tmpdir, "public"))
if keystoreURL != "" {
cfgstr += fmt.Sprintf(`
keystore:
backend_url: "%s"
`, keystoreURL)
}
ioutil.WriteFile(filepath.Join(tmpdir, "config"), []byte(cfgstr), 0600)
config, err := loadConfig(filepath.Join(tmpdir, "config"))
if err != nil {
......@@ -57,7 +65,7 @@ func TestLoginService_Ok(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "")
defer os.RemoveAll(tmpdir)
config := testConfig(t, tmpdir)
config := testConfig(t, tmpdir, "")
svc, err := NewLoginService(config)
if err != nil {
t.Fatal("NewLoginService():", err)
......@@ -85,7 +93,7 @@ func TestLoginService_SanityChecks(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "")
defer os.RemoveAll(tmpdir)
config := testConfig(t, tmpdir)
config := testConfig(t, tmpdir, "")
svc, err := NewLoginService(config)
if err != nil {
t.Fatal("NewLoginService():", err)
......
keystore
========
KeyStore holds *unencrypted* secrets on behalf of users in memory for
a short time (of the order of a SSO session lifespan). User secrets
can be *opened* with a password (used to decrypt the key, which is
stored encrypted in a database), *queried* by presenting a suitable
authentication token, and *closed* (wiped and forgotten).
The database can provide multiple versions of the encrypted key (to
support multiple decryption passwords), in which case we'll try
them all sequentially until one of them decrypts successfully with
the provided password.
In order to query the KeyStore, you need to present a valid SSO
token for the user whose secrets you would like to obtain.
# API
The server exports an API over HTTP/HTTPS. All requests should be made
using the POST method and a Content-Type of *application/json*. The
request body should contain a JSON-encoded object. Responses will be
similarly JSON-encoded.
`/api/open` (*OpenRequest*)
Retrieve the encrypted key for a user, decrypt it with the provided
password, and store it in memory.
OpenRequest is an object with the
following attributes:
* `username`
* `password` to decrypt the user's key with
* `ttl` (seconds) time after which the credentials are automatically
forgotten
`/api/get` (*GetRequest*) -> *GetResponse*
Retrieve the key for a user. GetRequest must contain the following
attributes:
* `username` whose key you wish to retrieve
* `sso_ticket` with a valid SSO ticket for the *keystore* service
If the request is successfully authenticated, GetResponse will contain
a single attribute *key*.
`/api/close` (*CloseRequest*)
Forget the key for a given user.
package client
import (
"context"
"crypto/tls"
"net/http"
"net/url"
"time"
"git.autistici.org/ai3/go-common/clientutil"
"git.autistici.org/id/keystore"
)
// Client for the keystore API.
type Client struct {
*http.Client
backendURL string
}
// Config for a keystore client.
type Config struct {
BackendURL string `yaml:"backend_url"`
TLSConfig *clientutil.TLSClientConfig `yaml:"tls_config"`
}
// New returns a new Client with the given Config.
func New(config *Config) (*Client, error) {
u, err := url.Parse(config.BackendURL)
if err != nil {
return nil, err
}
var tlsConfig *tls.Config
if config.TLSConfig != nil {
tlsConfig, err = config.TLSConfig.TLSConfig()
if err != nil {
return nil, err
}
}
c := &http.Client{
Transport: clientutil.NewTransport([]string{u.Host}, tlsConfig, nil),
Timeout: 20 * time.Second,
}
return &Client{
Client: c,
backendURL: config.BackendURL,
}, nil
}
func (c *Client) Open(ctx context.Context, username, password string, ttl int) error {
req := keystore.OpenRequest{
Username: username,
Password: password,
TTL: ttl,
}
var resp keystore.OpenResponse
return clientutil.DoJSONHTTPRequest(ctx, c.Client, c.backendURL+"/api/open", &req, &resp)
}
func (c *Client) Get(ctx context.Context, username, ssoTicket string) ([]byte, error) {
req := keystore.GetRequest{
Username: username,
SSOTicket: ssoTicket,
}
var resp keystore.GetResponse
err := clientutil.DoJSONHTTPRequest(ctx, c.Client, c.backendURL+"/api/get", &req, &resp)
return resp.Key, err
}
func (c *Client) Close(ctx context.Context, username string) error {
req := keystore.CloseRequest{
Username: username,
}
var resp keystore.CloseResponse
return clientutil.DoJSONHTTPRequest(ctx, c.Client, c.backendURL+"/api/close", &req, &resp)
}
package keystore
type OpenRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TTL int `json:"ttl"`
}
type OpenResponse struct{}
type GetRequest struct {
Username string `json:"username"`
SSOTicket string `json:"sso_ticket"`
}
type GetResponse struct {
Key []byte `json:"key"`
}
type CloseRequest struct {
Username string `json:"username"`
}
type CloseResponse struct{}
......@@ -32,6 +32,18 @@
"revision": "ddba7d73598682b17e4683f5a3873f12e158c679",
"revisionTime": "2017-12-13T22:22:39Z"
},
{
"checksumSHA1": "3alRLG3a43ORlVZyfQc/JsT0KtI=",
"path": "git.autistici.org/id/keystore",
"revision": "e7fb4821845b47ce2e372e384b49f4b5216bba93",
"revisionTime": "2017-12-09T17:36:20Z"
},
{
"checksumSHA1": "HGK52MX+2CEKVzb9I5y1BfgDkWQ=",
"path": "git.autistici.org/id/keystore/client",
"revision": "e7fb4821845b47ce2e372e384b49f4b5216bba93",
"revisionTime": "2017-12-09T17:36:20Z"
},
{
"checksumSHA1": "usT4LCSQItkFvFOQT7cBlkCuGaE=",
"path": "github.com/beevik/etree",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment