Skip to content

Commit

Permalink
Merge pull request juju#6180 from axw/login-local-macaroon-part2
Browse files Browse the repository at this point in the history
apiserver/authentication: introduce Interactions

Add the Interactions type, which will manage time-limited local-user
authentication interactions. This will be used in a following patch, which
introduces support for discharging third-party macaroons from Juju itself.

(Review request: http://reviews.vapour.ws/r/5618/)
  • Loading branch information
jujubot authored Sep 8, 2016
2 parents 33f0f3e + c1f2af7 commit ed5bf25
Show file tree
Hide file tree
Showing 5 changed files with 334 additions and 1 deletion.
19 changes: 19 additions & 0 deletions apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"gopkg.in/juju/names.v2"
"gopkg.in/tomb.v1"

"github.com/juju/juju/apiserver/authentication"
"github.com/juju/juju/apiserver/common"
"github.com/juju/juju/apiserver/common/apihttp"
"github.com/juju/juju/apiserver/observer"
Expand Down Expand Up @@ -287,6 +288,12 @@ func (srv *Server) run() {
srv.tomb.Kill(srv.mongoPinger())
}()

srv.wg.Add(1)
go func() {
defer srv.wg.Done()
srv.tomb.Kill(srv.expireLocalLoginInteractions())
}()

// for pat based handlers, they are matched in-order of being
// registered, first match wins. So more specific ones have to be
// registered first.
Expand Down Expand Up @@ -407,6 +414,18 @@ func (srv *Server) endpoints() []apihttp.Endpoint {
return endpoints
}

func (srv *Server) expireLocalLoginInteractions() error {
for {
select {
case <-srv.tomb.Dying():
return tomb.ErrDying
case <-time.After(authentication.LocalLoginInteractionTimeout):
now := srv.authCtxt.clock.Now()
srv.authCtxt.localUserInteractions.Expire(now)
}
}
}

func (srv *Server) newHandlerArgs(spec apihttp.HandlerConstraints) apihttp.NewHandlerArgs {
ctxt := httpContext{
srv: srv,
Expand Down
12 changes: 11 additions & 1 deletion apiserver/authcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ import (
type authContext struct {
st *state.State

clock clock.Clock
agentAuth authentication.AgentAuthenticator
userAuth authentication.UserAuthenticator

// localUserInteractions maintains a set of in-progress local user
// authentication interactions.
localUserInteractions *authentication.Interactions

// macaroonAuthOnce guards the fields below it.
macaroonAuthOnce sync.Once
_macaroonAuth *authentication.ExternalMacaroonAuthenticator
Expand All @@ -37,7 +42,12 @@ type authContext struct {

// newAuthContext creates a new authentication context for st.
func newAuthContext(st *state.State) (*authContext, error) {
ctxt := &authContext{st: st}
ctxt := &authContext{
st: st,
// TODO(fwereade) 2016-07-21 there should be a clock parameter
clock: clock.WallClock,
localUserInteractions: authentication.NewInteractions(),
}
store, err := st.NewBakeryStorage()
if err != nil {
return nil, errors.Trace(err)
Expand Down
136 changes: 136 additions & 0 deletions apiserver/authentication/interactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2016 Canonical Ltd. All rights reserved.
// Licensed under the AGPLv3, see LICENCE file for details.

package authentication

import (
"crypto/rand"
"fmt"
"sync"
"time"

"github.com/juju/errors"
"gopkg.in/juju/names.v2"
)

// ErrWaitCanceled is returned by Interactions.Wait when the cancel
// channel is signalled.
var ErrWaitCanceled = errors.New("wait canceled")

// ErrExpired is returned by Interactions.Wait when interactions expire
// before they are done.
var ErrExpired = errors.New("interaction timed out")

// Interactions maintains a set of Interactions.
type Interactions struct {
mu sync.Mutex
items map[string]*item
}

type item struct {
c chan Interaction
caveatId string
expiry time.Time
done bool
}

// Interaction records details of an in-progress interactive
// macaroon-based login.
type Interaction struct {
CaveatId string
LoginUser names.UserTag
LoginError error
}

// NewInteractions returns a new Interactions.
func NewInteractions() *Interactions {
return &Interactions{
items: make(map[string]*item),
}
}

func newId() (string, error) {
var id [12]byte
if _, err := rand.Read(id[:]); err != nil {
return "", fmt.Errorf("cannot read random id: %v", err)
}
return fmt.Sprintf("%x", id[:]), nil
}

// Start records the start of an interactive login, and returns a random ID
// that uniquely identifies it. A call to Wait with the same ID will return
// the Interaction once it is done.
func (m *Interactions) Start(caveatId string, expiry time.Time) (string, error) {
id, err := newId()
if err != nil {
return "", err
}
m.mu.Lock()
defer m.mu.Unlock()
m.items[id] = &item{
c: make(chan Interaction, 1),
caveatId: caveatId,
expiry: expiry,
}
return id, nil
}

// Done signals that the user has either logged in, or attempted to and failed.
func (m *Interactions) Done(id string, loginUser names.UserTag, loginError error) error {
m.mu.Lock()
defer m.mu.Unlock()
item := m.items[id]

if item == nil {
return errors.NotFoundf("interaction %q", id)
}
if item.done {
return errors.Errorf("interaction %q already done", id)
}
item.done = true
item.c <- Interaction{
CaveatId: item.caveatId,
LoginUser: loginUser,
LoginError: loginError,
}
return nil
}

// Wait waits until the identified interaction is done, and returns the
// corresponding Interaction. If the cancel channel is signalled before
// the interaction is done, then ErrWaitCanceled is returned. If the
// interaction expires before it is done, ErrExpired is returned.
func (m *Interactions) Wait(id string, cancel <-chan struct{}) (*Interaction, error) {
m.mu.Lock()
item := m.items[id]
m.mu.Unlock()
if item == nil {
return nil, errors.NotFoundf("interaction %q", id)
}
select {
case <-cancel:
return nil, ErrWaitCanceled
case interaction, ok := <-item.c:
if !ok {
return nil, ErrExpired
}
m.mu.Lock()
delete(m.items, id)
m.mu.Unlock()
return &interaction, nil
}
}

// Expire removes any interactions that were due to expire by the
// specified time.
func (m *Interactions) Expire(t time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
for id, item := range m.items {
if item.done || item.expiry.After(t) {
continue
}
delete(m.items, id)
close(item.c)
}
}
164 changes: 164 additions & 0 deletions apiserver/authentication/interactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright 2016 Canonical Ltd. All rights reserved.
// Licensed under the AGPLv3, see LICENCE file for details.

package authentication_test

import (
"time"

"github.com/juju/errors"
"github.com/juju/testing"
jc "github.com/juju/testing/checkers"
gc "gopkg.in/check.v1"
"gopkg.in/juju/names.v2"

"github.com/juju/juju/apiserver/authentication"
coretesting "github.com/juju/juju/testing"
)

type InteractionsSuite struct {
testing.IsolationSuite
interactions *authentication.Interactions
}

var _ = gc.Suite(&InteractionsSuite{})

func (s *InteractionsSuite) SetUpTest(c *gc.C) {
s.IsolationSuite.SetUpTest(c)
s.interactions = authentication.NewInteractions()
}

func (s *InteractionsSuite) TestStart(c *gc.C) {
waitId, err := s.interactions.Start("caveat-id", time.Time{})
c.Assert(err, jc.ErrorIsNil)
c.Assert(waitId, gc.Not(gc.Equals), "")
}

func (s *InteractionsSuite) TestDone(c *gc.C) {
waitId := s.start(c, "caveat-id")
err := s.interactions.Done(waitId, names.NewUserTag("admin@local"), nil)
c.Assert(err, jc.ErrorIsNil)
}

func (s *InteractionsSuite) TestDoneNotFound(c *gc.C) {
err := s.interactions.Done("not-found", names.NewUserTag("admin@local"), nil)
c.Assert(err, jc.Satisfies, errors.IsNotFound)
c.Assert(err, gc.ErrorMatches, `interaction "not-found" not found`)
}

func (s *InteractionsSuite) TestDoneTwice(c *gc.C) {
waitId := s.start(c, "caveat-id")
err := s.interactions.Done(waitId, names.NewUserTag("admin@local"), nil)
c.Assert(err, jc.ErrorIsNil)
err = s.interactions.Done(waitId, names.NewUserTag("admin@local"), nil)
c.Assert(err, gc.ErrorMatches, `interaction ".*" already done`)
}

func (s *InteractionsSuite) TestWait(c *gc.C) {
waitId := s.start(c, "caveat-id")
loginUser := names.NewUserTag("admin@local")
loginError := errors.New("login failed")
s.done(c, waitId, loginUser, loginError)
interaction, err := s.interactions.Wait(waitId, nil)
c.Assert(err, jc.ErrorIsNil)
c.Assert(interaction, gc.NotNil)
c.Assert(interaction, jc.DeepEquals, &authentication.Interaction{
CaveatId: "caveat-id",
LoginUser: loginUser,
LoginError: loginError,
})
}

func (s *InteractionsSuite) TestWaitNotFound(c *gc.C) {
interaction, err := s.interactions.Wait("not-found", nil)
c.Assert(err, gc.ErrorMatches, `interaction "not-found" not found`)
c.Assert(interaction, gc.IsNil)
}

func (s *InteractionsSuite) TestWaitTwice(c *gc.C) {
waitId := s.start(c, "caveat-id")
s.done(c, waitId, names.NewUserTag("admin@local"), nil)

_, err := s.interactions.Wait(waitId, nil)
c.Assert(err, jc.ErrorIsNil)

// The Wait call above should have removed the item.
_, err = s.interactions.Wait(waitId, nil)
c.Assert(err, gc.ErrorMatches, `interaction ".*" not found`)
}

func (s *InteractionsSuite) TestWaitCancellation(c *gc.C) {
waitId := s.start(c, "caveat-id")

cancel := make(chan struct{})
waitResult := make(chan error)
go func() {
_, err := s.interactions.Wait(waitId, cancel)
waitResult <- err
}()

// Wait should not pass until we've cancelled.
select {
case err := <-waitResult:
c.Fatalf("unexpected result: %v", err)
case <-time.After(coretesting.ShortWait):
}

cancel <- struct{}{}
select {
case err := <-waitResult:
c.Assert(err, gc.Equals, authentication.ErrWaitCanceled)
case <-time.After(coretesting.LongWait):
c.Fatalf("timed out waiting for Wait to return")
}
}

func (s *InteractionsSuite) TestWaitExpired(c *gc.C) {
t0 := time.Now()
t1 := t0.Add(time.Second)
t2 := t1.Add(time.Second)

waitId, err := s.interactions.Start("caveat-id", t2)
c.Assert(err, jc.ErrorIsNil)

type waitResult struct {
interaction *authentication.Interaction
err error
}
waitResultC := make(chan waitResult)
go func() {
interaction, err := s.interactions.Wait(waitId, nil)
waitResultC <- waitResult{interaction, err}
}()

// This should do nothing, because there's nothing
// due to expire until t2.
s.interactions.Expire(t1)

// Wait should not pass until the interaction expires.
select {
case result := <-waitResultC:
c.Fatalf("unexpected result: %v", result)
case <-time.After(coretesting.ShortWait):
}

s.interactions.Expire(t2)
select {
case result := <-waitResultC:
c.Assert(result.err, gc.Equals, authentication.ErrExpired)
c.Assert(result.interaction, gc.IsNil)
case <-time.After(coretesting.LongWait):
c.Fatalf("timed out waiting for Wait to return")
}
}

func (s *InteractionsSuite) start(c *gc.C, caveatId string) string {
waitId, err := s.interactions.Start(caveatId, time.Time{})
c.Assert(err, jc.ErrorIsNil)
return waitId
}

func (s *InteractionsSuite) done(c *gc.C, waitId string, loginUser names.UserTag, loginError error) {
err := s.interactions.Done(waitId, loginUser, loginError)
c.Assert(err, jc.ErrorIsNil)
}
4 changes: 4 additions & 0 deletions apiserver/authentication/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type UserAuthenticator struct {
const (
usernameKey = "username"

// LocalLoginInteractionTimeout is how long a user has to complete
// an interactive login before it is expired.
LocalLoginInteractionTimeout = 2 * time.Minute

// TODO(axw) make this configurable via model config.
localLoginExpiryTime = 24 * time.Hour

Expand Down

0 comments on commit ed5bf25

Please sign in to comment.