summaryrefslogtreecommitdiff
path: root/macaroonbakery/httpbakery/agent/_agent.py
blob: 618097cb30bab37f88470f98873f5fdb5a53ee08 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
import copy
import json
import logging
from collections import namedtuple

import macaroonbakery.bakery as bakery
import macaroonbakery.httpbakery as httpbakery
import macaroonbakery._utils as utils
import requests.cookies

from six.moves.urllib.parse import urljoin

log = logging.getLogger(__name__)


class AgentFileFormatError(Exception):
    ''' AgentFileFormatError is the exception raised when an agent file has a
        bad structure.
    '''
    pass


def load_auth_info(filename):
    '''Loads agent authentication information from the specified file.
    The returned information is suitable for passing as an argument
    to the AgentInteractor constructor.
    @param filename The name of the file to open (str)
    @return AuthInfo The authentication information
    @raises AgentFileFormatError when the file format is bad.
    '''
    with open(filename) as f:
        return read_auth_info(f.read())


def read_auth_info(agent_file_content):
    '''Loads agent authentication information from the
    specified content string, as read from an agents file.
    The returned information is suitable for passing as an argument
    to the AgentInteractor constructor.
    @param agent_file_content The agent file content (str)
    @return AuthInfo The authentication information
    @raises AgentFileFormatError when the file format is bad.
    '''
    try:
        data = json.loads(agent_file_content)
        return AuthInfo(
            key=bakery.PrivateKey.deserialize(data['key']['private']),
            agents=list(
                Agent(url=a['url'], username=a['username'])
                for a in data.get('agents', [])
            ),
        )
    except (
        KeyError,
        ValueError,
        TypeError,
    ) as e:
        raise AgentFileFormatError('invalid agent file', e)


class InteractionInfo(object):
    '''Holds the information expected in the agent interaction entry in an
    interaction-required error.
    '''
    def __init__(self, login_url):
        self._login_url = login_url

    @property
    def login_url(self):
        ''' Return the URL from which to acquire a macaroon that can be used
        to complete the agent login. To acquire the macaroon, make a POST
        request to the URL with user and public-key parameters.
        :return string
        '''
        return self._login_url

    @classmethod
    def from_dict(cls, json_dict):
        '''Return an InteractionInfo obtained from the given dictionary as
        deserialized from JSON.
        @param json_dict The deserialized JSON object.
        '''
        return InteractionInfo(json_dict.get('login-url'))


class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor):
    ''' Interactor that performs interaction using the agent login protocol.
    '''
    def __init__(self, auth_info):
        self._auth_info = auth_info

    def kind(self):
        '''Implement Interactor.kind by returning the agent kind'''
        return 'agent'

    def interact(self, client, location, interaction_required_err):
        '''Implement Interactor.interact by obtaining obtaining
        a macaroon from the discharger, discharging it with the
        local private key using the discharged macaroon as
        a discharge token'''
        p = interaction_required_err.interaction_method('agent',
                                                        InteractionInfo)
        if p.login_url is None or p.login_url == '':
            raise httpbakery.InteractionError(
                'no login-url field found in agent interaction method')
        agent = self._find_agent(location)
        if not location.endswith('/'):
            location += '/'
        login_url = urljoin(location, p.login_url)
        resp = requests.get(
            login_url, params={
                'username': agent.username,
                'public-key': str(self._auth_info.key.public_key)},
            auth=client.auth())
        if resp.status_code != 200:
            raise httpbakery.InteractionError(
                'cannot acquire agent macaroon: {} {}'.format(
                    resp.status_code, resp.text)
            )
        m = resp.json().get('macaroon')
        if m is None:
            raise httpbakery.InteractionError('no macaroon in response')
        m = bakery.Macaroon.from_dict(m)
        ms = bakery.discharge_all(m, None, self._auth_info.key)
        b = bytearray()
        for m in ms:
            b.extend(utils.b64decode(m.serialize()))
        return httpbakery.DischargeToken(kind='agent', value=bytes(b))

    def _find_agent(self, location):
        ''' Finds an appropriate agent entry for the given location.
        :return Agent
        '''
        for a in self._auth_info.agents:
            # Don't worry about trailing slashes
            if a.url.rstrip('/') == location.rstrip('/'):
                return a
        raise httpbakery.InteractionMethodNotFound(
            'cannot find username for discharge location {}'.format(location))

    def legacy_interact(self, client, location, visit_url):
        '''Implement LegacyInteractor.legacy_interact by obtaining
        the discharge macaroon using the client's private key
        '''
        agent = self._find_agent(location)
        # Shallow-copy the client so that we don't unexpectedly side-effect
        # it by changing the key. Another possibility might be to
        # set up agent authentication differently, in such a way that
        # we're sure that client.key is the same as self._auth_info.key.
        client = copy.copy(client)
        client.key = self._auth_info.key
        resp = client.request(
            method='POST',
            url=visit_url,
            json={
                'username': agent.username,
                'public_key': str(self._auth_info.key.public_key),
            },
        )
        if resp.status_code != 200:
            raise httpbakery.InteractionError(
                'cannot acquire agent macaroon from {}: {} (response body: {!r})'.format(visit_url, resp.status_code, resp.text))
        if not resp.json().get('agent_login', False):
            raise httpbakery.InteractionError('agent login failed')


class Agent(namedtuple('Agent', 'url, username')):
    ''' Represents an agent that can be used for agent authentication.
    @param url(string) holds the URL of the discharger that knows about
    the agent.
    @param username holds the username agent (string).
    '''


class AuthInfo(namedtuple('AuthInfo', 'key, agents')):
    ''' Holds the agent information required to set up agent authentication
    information.

    It holds the agent's private key and information about the username
    associated with each known agent-authentication server.
    @param key the agent's private key (bakery.PrivateKey).
    @param agents information about the known agents (list of Agent).
    '''