login.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package login
  14. import (
  15. "context"
  16. "encoding/json"
  17. "fmt"
  18. "net/http"
  19. "net/url"
  20. "os"
  21. "strconv"
  22. "time"
  23. "github.com/Azure/go-autorest/autorest"
  24. "github.com/Azure/go-autorest/autorest/adal"
  25. "github.com/Azure/go-autorest/autorest/azure/auth"
  26. "github.com/Azure/go-autorest/autorest/date"
  27. "github.com/pkg/errors"
  28. "golang.org/x/oauth2"
  29. "github.com/docker/compose-cli/errdefs"
  30. )
  31. //go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff
  32. const (
  33. // AcrRegistrySuffix suffix for ACR registry images
  34. AcrRegistrySuffix = ".azurecr.io"
  35. activeDirectoryURL = "https://login.microsoftonline.com"
  36. azureManagementURL = "https://management.core.windows.net/"
  37. azureResouceManagementURL = "https://management.azure.com/"
  38. authorizeFormat = activeDirectoryURL + "/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s"
  39. tokenEndpoint = activeDirectoryURL + "/%s/oauth2/v2.0/token"
  40. getTenantURL = azureResouceManagementURL + "tenants?api-version=2019-11-01"
  41. // scopes for a multi-tenant app works for openid, email, other common scopes, but fails when trying to add a token
  42. // v1 scope like "https://management.azure.com/.default" for ARM access
  43. scopes = "offline_access " + azureResouceManagementURL + ".default"
  44. clientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" // Azure CLI client id
  45. )
  46. type (
  47. azureToken struct {
  48. Type string `json:"token_type"`
  49. Scope string `json:"scope"`
  50. ExpiresIn int `json:"expires_in"`
  51. ExtExpiresIn int `json:"ext_expires_in"`
  52. AccessToken string `json:"access_token"`
  53. RefreshToken string `json:"refresh_token"`
  54. Foci string `json:"foci"`
  55. }
  56. tenantResult struct {
  57. Value []tenantValue `json:"value"`
  58. }
  59. tenantValue struct {
  60. TenantID string `json:"tenantId"`
  61. }
  62. )
  63. // AzureLoginService Service to log into azure and get authentifier for azure APIs
  64. type AzureLoginService struct {
  65. tokenStore tokenStore
  66. apiHelper apiHelper
  67. }
  68. // AzureLoginServiceAPI interface for Azure login service
  69. type AzureLoginServiceAPI interface {
  70. LoginServicePrincipal(clientID string, clientSecret string, tenantID string) error
  71. Login(ctx context.Context, requestedTenantID string) error
  72. Logout(ctx context.Context) error
  73. }
  74. const tokenStoreFilename = "dockerAccessToken.json"
  75. // NewAzureLoginService creates a NewAzureLoginService
  76. func NewAzureLoginService() (*AzureLoginService, error) {
  77. return newAzureLoginServiceFromPath(GetTokenStorePath(), azureAPIHelper{})
  78. }
  79. func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (*AzureLoginService, error) {
  80. store, err := newTokenStore(tokenStorePath)
  81. if err != nil {
  82. return nil, err
  83. }
  84. return &AzureLoginService{
  85. tokenStore: store,
  86. apiHelper: helper,
  87. }, nil
  88. }
  89. // LoginServicePrincipal login with clientId / clientSecret from a service principal.
  90. // The resulting token does not include a refresh token
  91. func (login *AzureLoginService) LoginServicePrincipal(clientID string, clientSecret string, tenantID string) error {
  92. // Tried with auth2.NewUsernamePasswordConfig() but could not make this work with username / password, setting this for CI with clientID / clientSecret
  93. creds := auth.NewClientCredentialsConfig(clientID, clientSecret, tenantID)
  94. spToken, err := creds.ServicePrincipalToken()
  95. if err != nil {
  96. return errors.Wrapf(errdefs.ErrLoginFailed, "could not login with service principal: %s", err)
  97. }
  98. err = spToken.Refresh()
  99. if err != nil {
  100. return errors.Wrapf(errdefs.ErrLoginFailed, "could not login with service principal: %s", err)
  101. }
  102. token, err := spToOAuthToken(spToken.Token())
  103. if err != nil {
  104. return errors.Wrapf(errdefs.ErrLoginFailed, "could not read service principal token expiry: %s", err)
  105. }
  106. loginInfo := TokenInfo{TenantID: tenantID, Token: token}
  107. if err := login.tokenStore.writeLoginInfo(loginInfo); err != nil {
  108. return errors.Wrapf(errdefs.ErrLoginFailed, "could not store login info: %s", err)
  109. }
  110. return nil
  111. }
  112. // Logout remove azure token data
  113. func (login *AzureLoginService) Logout(ctx context.Context) error {
  114. err := login.tokenStore.removeData()
  115. if os.IsNotExist(err) {
  116. return errors.New("No Azure login data to be removed")
  117. }
  118. return err
  119. }
  120. func (login *AzureLoginService) getTenantAndValidateLogin(ctx context.Context, accessToken string, refreshToken string, requestedTenantID string) error {
  121. bits, statusCode, err := login.apiHelper.queryAPIWithHeader(ctx, getTenantURL, fmt.Sprintf("Bearer %s", accessToken))
  122. if err != nil {
  123. return errors.Wrapf(errdefs.ErrLoginFailed, "check auth failed: %s", err)
  124. }
  125. if statusCode != http.StatusOK {
  126. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to login status code %d: %s", statusCode, bits)
  127. }
  128. var t tenantResult
  129. if err := json.Unmarshal(bits, &t); err != nil {
  130. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to unmarshal tenant: %s", err)
  131. }
  132. tenantID, err := getTenantID(t.Value, requestedTenantID)
  133. if err != nil {
  134. return errors.Wrap(errdefs.ErrLoginFailed, err.Error())
  135. }
  136. tToken, err := login.refreshToken(refreshToken, tenantID)
  137. if err != nil {
  138. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to refresh token: %s", err)
  139. }
  140. loginInfo := TokenInfo{TenantID: tenantID, Token: tToken}
  141. if err := login.tokenStore.writeLoginInfo(loginInfo); err != nil {
  142. return errors.Wrapf(errdefs.ErrLoginFailed, "could not store login info: %s", err)
  143. }
  144. return nil
  145. }
  146. // Login performs an Azure login through a web browser
  147. func (login *AzureLoginService) Login(ctx context.Context, requestedTenantID string) error {
  148. queryCh := make(chan localResponse, 1)
  149. s, err := NewLocalServer(queryCh)
  150. if err != nil {
  151. return err
  152. }
  153. s.Serve()
  154. defer s.Close()
  155. redirectURL := s.Addr()
  156. if redirectURL == "" {
  157. return errors.Wrap(errdefs.ErrLoginFailed, "empty redirect URL")
  158. }
  159. deviceCodeFlowCh := make(chan deviceCodeFlowResponse, 1)
  160. if err = login.apiHelper.openAzureLoginPage(redirectURL); err != nil {
  161. login.startDeviceCodeFlow(deviceCodeFlowCh)
  162. }
  163. select {
  164. case <-ctx.Done():
  165. return ctx.Err()
  166. case dcft := <-deviceCodeFlowCh:
  167. if dcft.err != nil {
  168. return errors.Wrapf(errdefs.ErrLoginFailed, "could not get token using device code flow: %s", err)
  169. }
  170. token := dcft.token
  171. return login.getTenantAndValidateLogin(ctx, token.AccessToken, token.RefreshToken, requestedTenantID)
  172. case q := <-queryCh:
  173. if q.err != nil {
  174. return errors.Wrapf(errdefs.ErrLoginFailed, "unhandled local login server error: %s", err)
  175. }
  176. code, hasCode := q.values["code"]
  177. if !hasCode {
  178. return errors.Wrap(errdefs.ErrLoginFailed, "no login code")
  179. }
  180. data := url.Values{
  181. "grant_type": []string{"authorization_code"},
  182. "client_id": []string{clientID},
  183. "code": code,
  184. "scope": []string{scopes},
  185. "redirect_uri": []string{redirectURL},
  186. }
  187. token, err := login.apiHelper.queryToken(data, "organizations")
  188. if err != nil {
  189. return errors.Wrapf(errdefs.ErrLoginFailed, "access token request failed: %s", err)
  190. }
  191. return login.getTenantAndValidateLogin(ctx, token.AccessToken, token.RefreshToken, requestedTenantID)
  192. }
  193. }
  194. type deviceCodeFlowResponse struct {
  195. token adal.Token
  196. err error
  197. }
  198. func (login *AzureLoginService) startDeviceCodeFlow(deviceCodeFlowCh chan deviceCodeFlowResponse) {
  199. fmt.Println("Could not automatically open a browser, falling back to Azure device code flow authentication")
  200. go func() {
  201. token, err := login.apiHelper.getDeviceCodeFlowToken()
  202. if err != nil {
  203. deviceCodeFlowCh <- deviceCodeFlowResponse{err: err}
  204. }
  205. deviceCodeFlowCh <- deviceCodeFlowResponse{token: token}
  206. }()
  207. }
  208. func getTenantID(tenantValues []tenantValue, requestedTenantID string) (string, error) {
  209. if requestedTenantID == "" {
  210. if len(tenantValues) < 1 {
  211. return "", errors.Errorf("could not find azure tenant")
  212. }
  213. return tenantValues[0].TenantID, nil
  214. }
  215. for _, tValue := range tenantValues {
  216. if tValue.TenantID == requestedTenantID {
  217. return tValue.TenantID, nil
  218. }
  219. }
  220. return "", errors.Errorf("could not find requested azure tenant %s", requestedTenantID)
  221. }
  222. func toOAuthToken(token azureToken) oauth2.Token {
  223. expireTime := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
  224. oauthToken := oauth2.Token{
  225. RefreshToken: token.RefreshToken,
  226. AccessToken: token.AccessToken,
  227. Expiry: expireTime,
  228. TokenType: token.Type,
  229. }
  230. return oauthToken
  231. }
  232. func spToOAuthToken(token adal.Token) (oauth2.Token, error) {
  233. expiresIn, err := token.ExpiresIn.Int64()
  234. if err != nil {
  235. return oauth2.Token{}, err
  236. }
  237. expireTime := time.Now().Add(time.Duration(expiresIn) * time.Second)
  238. oauthToken := oauth2.Token{
  239. RefreshToken: token.RefreshToken,
  240. AccessToken: token.AccessToken,
  241. Expiry: expireTime,
  242. TokenType: token.Type,
  243. }
  244. return oauthToken, nil
  245. }
  246. // NewAuthorizerFromLogin creates an authorizer based on login access token
  247. func NewAuthorizerFromLogin() (autorest.Authorizer, error) {
  248. return newAuthorizerFromLoginStorePath(GetTokenStorePath())
  249. }
  250. func newAuthorizerFromLoginStorePath(storeTokenPath string) (autorest.Authorizer, error) {
  251. login, err := newAzureLoginServiceFromPath(storeTokenPath, azureAPIHelper{})
  252. if err != nil {
  253. return nil, err
  254. }
  255. oauthToken, err := login.GetValidToken()
  256. if err != nil {
  257. return nil, errors.Wrap(err, "not logged in to azure, you need to run \"docker login azure\" first")
  258. }
  259. token := adal.Token{
  260. AccessToken: oauthToken.AccessToken,
  261. Type: oauthToken.TokenType,
  262. ExpiresIn: json.Number(strconv.Itoa(int(time.Until(oauthToken.Expiry).Seconds()))),
  263. ExpiresOn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(date.UnixEpoch()).Seconds()))),
  264. RefreshToken: "",
  265. Resource: "",
  266. }
  267. return autorest.NewBearerAuthorizer(&token), nil
  268. }
  269. // GetTenantID returns tenantID for current login
  270. func (login AzureLoginService) GetTenantID() (string, error) {
  271. loginInfo, err := login.tokenStore.readToken()
  272. if err != nil {
  273. return "", err
  274. }
  275. return loginInfo.TenantID, err
  276. }
  277. // GetValidToken returns an access token. Refresh token if needed
  278. func (login *AzureLoginService) GetValidToken() (oauth2.Token, error) {
  279. loginInfo, err := login.tokenStore.readToken()
  280. if err != nil {
  281. return oauth2.Token{}, err
  282. }
  283. token := loginInfo.Token
  284. if token.Valid() {
  285. return token, nil
  286. }
  287. tenantID := loginInfo.TenantID
  288. token, err = login.refreshToken(token.RefreshToken, tenantID)
  289. if err != nil {
  290. return oauth2.Token{}, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.")
  291. }
  292. err = login.tokenStore.writeLoginInfo(TokenInfo{TenantID: tenantID, Token: token})
  293. if err != nil {
  294. return oauth2.Token{}, err
  295. }
  296. return token, nil
  297. }
  298. func (login *AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauth2.Token, error) {
  299. data := url.Values{
  300. "grant_type": []string{"refresh_token"},
  301. "client_id": []string{clientID},
  302. "scope": []string{scopes},
  303. "refresh_token": []string{currentRefreshToken},
  304. }
  305. token, err := login.apiHelper.queryToken(data, tenantID)
  306. if err != nil {
  307. return oauth2.Token{}, err
  308. }
  309. return toOAuthToken(token), nil
  310. }