Skip to content

Commit

Permalink
cmd/juju/user: support logging in as external user
Browse files Browse the repository at this point in the history
If you type "juju login" without specifying a user,
and there is no active account, then we will attempt
to log in using external user macaroon auth. If that
fails for a reason other than failure to authenticate,
then we'll fall back to logging in as a local user.
  • Loading branch information
axw committed Sep 8, 2016
1 parent 3214914 commit dac3bdc
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 13 deletions.
3 changes: 1 addition & 2 deletions apiserver/authcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ func (ctxt *authContext) authenticatorForTag(tag names.Tag) (authentication.Enti
if tag == nil {
auth, err := ctxt.macaroonAuth()
if errors.Cause(err) == errMacaroonAuthNotConfigured {
// Make a friendlier error message.
err = errors.New("no credentials provided")
err = errors.Trace(common.ErrNoCreds)
}
if err != nil {
return nil, errors.Trace(err)
Expand Down
2 changes: 2 additions & 0 deletions apiserver/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func IsUpgradeInProgressError(err error) bool {
var (
ErrBadId = errors.New("id not found")
ErrBadCreds = errors.New("invalid entity name or password")
ErrNoCreds = errors.New("no credentials provided")
ErrLoginExpired = errors.New("login expired")
ErrPerm = errors.New("permission denied")
ErrNotLoggedIn = errors.New("not logged in")
Expand Down Expand Up @@ -122,6 +123,7 @@ var singletonErrorCodes = map[error]string{
lease.ErrClaimDenied: params.CodeLeaseClaimDenied,
ErrBadId: params.CodeNotFound,
ErrBadCreds: params.CodeUnauthorized,
ErrNoCreds: params.CodeNoCreds,
ErrLoginExpired: params.CodeLoginExpired,
ErrPerm: params.CodeUnauthorized,
ErrNotLoggedIn: params.CodeUnauthorized,
Expand Down
5 changes: 5 additions & 0 deletions apiserver/params/apierror.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const (
CodeModelNotFound = "model not found"
CodeUnauthorized = "unauthorized access"
CodeLoginExpired = "login expired"
CodeNoCreds = "no credentials provided"
CodeCannotEnterScope = "cannot enter scope"
CodeCannotEnterScopeYet = "cannot enter scope yet"
CodeExcessiveContention = "excessive contention"
Expand Down Expand Up @@ -126,6 +127,10 @@ func IsCodeUnauthorized(err error) bool {
return ErrCode(err) == CodeUnauthorized
}

func IsCodeNoCreds(err error) bool {
return ErrCode(err) == CodeNoCreds
}

func IsCodeLoginExpired(err error) bool {
return ErrCode(err) == CodeLoginExpired
}
Expand Down
38 changes: 32 additions & 6 deletions cmd/juju/user/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"gopkg.in/macaroon.v1"

"github.com/juju/juju/api/usermanager"
"github.com/juju/juju/apiserver/params"
"github.com/juju/juju/cmd/modelcmd"
"github.com/juju/juju/juju"
"github.com/juju/juju/jujuclient"
Expand Down Expand Up @@ -80,18 +81,47 @@ type LoginAPI interface {

// ConnectionAPI provides relevant API methods off the underlying connection.
type ConnectionAPI interface {
AuthTag() names.Tag
ControllerAccess() string
}

// Run implements Command.Run.
func (c *loginCommand) Run(ctx *cmd.Context) error {
controllerName := c.ControllerName()
store := c.ClientStore()
accountDetails, err := store.AccountDetails(controllerName)
if err != nil && !errors.IsNotFound(err) {
return errors.Trace(err)
}

user := c.User
if user == "" && accountDetails == nil {
// The username has not been specified, and there
// is no current account. See if the user can log
// in with macaroons.
args, err := c.NewAPIConnectionParams(
store, controllerName, "",
&jujuclient.AccountDetails{},
)
if err != nil {
return errors.Trace(err)
}
api, conn, err := c.newLoginAPI(args)
if err == nil {
authTag := conn.AuthTag()
api.Close()
ctx.Infof("You are now logged in to %q as %q.", controllerName, authTag.Id())
return nil
}
if !params.IsCodeNoCreds(err) {
return errors.Annotate(err, "creating API connection")
}
// CodeNoCreds was returned, which means that external
// users are not supported. Fall back to prompting the
// user for their username and password.
}

if user == "" {
// TODO(rog) Try macaroon login first before
// falling back to prompting for username.
// The username has not been specified, so prompt for it.
fmt.Fprint(ctx.Stderr, "username: ")
var err error
Expand All @@ -111,10 +141,6 @@ func (c *loginCommand) Run(ctx *cmd.Context) error {
// Make sure that the client is not already logged in,
// or if it is, that it is logged in as the specified
// user.
accountDetails, err := store.AccountDetails(controllerName)
if err != nil && !errors.IsNotFound(err) {
return errors.Trace(err)
}
if accountDetails != nil && accountDetails.User != userTag.Canonical() {
return errors.New(`already logged in
Expand Down
41 changes: 40 additions & 1 deletion cmd/juju/user/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
gc "gopkg.in/check.v1"
"gopkg.in/juju/names.v2"

"github.com/juju/juju/apiserver/params"
"github.com/juju/juju/cmd/juju/user"
"github.com/juju/juju/juju"
"github.com/juju/juju/jujuclient"
Expand All @@ -20,14 +21,16 @@ import (

type LoginCommandSuite struct {
BaseSuite
mockAPI *mockLoginAPI
mockAPI *mockLoginAPI
loginErr error
}

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

func (s *LoginCommandSuite) SetUpTest(c *gc.C) {
s.BaseSuite.SetUpTest(c)
s.mockAPI = &mockLoginAPI{}
s.loginErr = nil
}

func (s *LoginCommandSuite) run(c *gc.C, stdin string, args ...string) (*cmd.Context, juju.NewAPIConnectionParams, error) {
Expand All @@ -37,6 +40,11 @@ func (s *LoginCommandSuite) run(c *gc.C, stdin string, args ...string) (*cmd.Con
// The account details are modified in place, so take a copy.
accountDetails := *argsOut.AccountDetails
argsOut.AccountDetails = &accountDetails
if s.loginErr != nil {
err := s.loginErr
s.loginErr = nil
return nil, nil, err
}
return s.mockAPI, s.mockAPI, nil
}, s.store)
ctx := coretesting.Context(c)
Expand Down Expand Up @@ -139,10 +147,41 @@ func (s *LoginCommandSuite) TestLoginFail(c *gc.C) {
s.assertStoreMacaroon(c, "current-user@local", nil)
}

func (s *LoginCommandSuite) TestLoginWithMacaroons(c *gc.C) {
err := s.store.RemoveAccount("testing")
c.Assert(err, jc.ErrorIsNil)
context, args, err := s.run(c, "")
c.Assert(err, jc.ErrorIsNil)
c.Assert(coretesting.Stdout(context), gc.Equals, "")
c.Assert(coretesting.Stderr(context), gc.Equals, `
You are now logged in to "testing" as "user@external".
`[1:],
)
c.Assert(args.AccountDetails, jc.DeepEquals, &jujuclient.AccountDetails{})
}

func (s *LoginCommandSuite) TestLoginWithMacaroonsNotSupported(c *gc.C) {
err := s.store.RemoveAccount("testing")
c.Assert(err, jc.ErrorIsNil)
s.loginErr = &params.Error{Code: params.CodeNoCreds, Message: "barf"}
context, _, err := s.run(c, "new-user\nsekrit\n")
c.Assert(err, jc.ErrorIsNil)
c.Assert(coretesting.Stdout(context), gc.Equals, "")
c.Assert(coretesting.Stderr(context), gc.Equals, `
username: password:
You are now logged in to "testing" as "new-user@local".
`[1:],
)
}

type mockLoginAPI struct {
mockChangePasswordAPI
}

func (*mockLoginAPI) AuthTag() names.Tag {
return names.NewUserTag("user@external")
}

func (*mockLoginAPI) ControllerAccess() string {
return "superuser"
}
10 changes: 6 additions & 4 deletions cmd/modelcmd/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,12 @@ func newAPIConnectionParams(
dialOpts := api.DefaultDialOpts()
dialOpts.BakeryClient = bakery

bakery.WebPageVisitor = httpbakery.NewMultiVisitor(
authentication.NewVisitor(accountDetails.User, getPassword),
bakery.WebPageVisitor,
)
if accountDetails != nil {
bakery.WebPageVisitor = httpbakery.NewMultiVisitor(
authentication.NewVisitor(accountDetails.User, getPassword),
bakery.WebPageVisitor,
)
}

return juju.NewAPIConnectionParams{
Store: store,
Expand Down

0 comments on commit dac3bdc

Please sign in to comment.