login.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /*
  2. Copyright 2020 Docker, Inc.
  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. "log"
  19. "net/http"
  20. "net/url"
  21. "os/exec"
  22. "path/filepath"
  23. "runtime"
  24. "strconv"
  25. "time"
  26. "github.com/Azure/go-autorest/autorest"
  27. "github.com/Azure/go-autorest/autorest/adal"
  28. "github.com/Azure/go-autorest/autorest/azure/cli"
  29. "github.com/Azure/go-autorest/autorest/date"
  30. "github.com/pkg/errors"
  31. "golang.org/x/oauth2"
  32. "github.com/docker/api/errdefs"
  33. )
  34. //go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff
  35. const (
  36. authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s"
  37. tokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
  38. authorizationURL = "https://management.azure.com/tenants?api-version=2019-11-01"
  39. // scopes for a multi-tenant app works for openid, email, other common scopes, but fails when trying to add a token
  40. // v1 scope like "https://management.azure.com/.default" for ARM access
  41. scopes = "offline_access https://management.azure.com/.default"
  42. clientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" // Azure CLI client id
  43. )
  44. type (
  45. azureToken struct {
  46. Type string `json:"token_type"`
  47. Scope string `json:"scope"`
  48. ExpiresIn int `json:"expires_in"`
  49. ExtExpiresIn int `json:"ext_expires_in"`
  50. AccessToken string `json:"access_token"`
  51. RefreshToken string `json:"refresh_token"`
  52. Foci string `json:"foci"`
  53. }
  54. tenantResult struct {
  55. Value []tenantValue `json:"value"`
  56. }
  57. tenantValue struct {
  58. TenantID string `json:"tenantId"`
  59. }
  60. )
  61. // AzureLoginService Service to log into azure and get authentifier for azure APIs
  62. type AzureLoginService struct {
  63. tokenStore tokenStore
  64. apiHelper apiHelper
  65. }
  66. const tokenStoreFilename = "dockerAccessToken.json"
  67. // NewAzureLoginService creates a NewAzureLoginService
  68. func NewAzureLoginService() (AzureLoginService, error) {
  69. return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{})
  70. }
  71. func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (AzureLoginService, error) {
  72. store, err := newTokenStore(tokenStorePath)
  73. if err != nil {
  74. return AzureLoginService{}, err
  75. }
  76. return AzureLoginService{
  77. tokenStore: store,
  78. apiHelper: helper,
  79. }, nil
  80. }
  81. // Login performs an Azure login through a web browser
  82. func (login AzureLoginService) Login(ctx context.Context) error {
  83. queryCh := make(chan localResponse, 1)
  84. s, err := NewLocalServer(queryCh)
  85. if err != nil {
  86. return err
  87. }
  88. s.Serve()
  89. defer s.Close()
  90. redirectURL := s.Addr()
  91. if redirectURL == "" {
  92. return errors.Wrap(errdefs.ErrLoginFailed, "empty redirect URL")
  93. }
  94. login.apiHelper.openAzureLoginPage(redirectURL)
  95. select {
  96. case <-ctx.Done():
  97. return ctx.Err()
  98. case q := <-queryCh:
  99. if q.err != nil {
  100. return errors.Wrapf(errdefs.ErrLoginFailed, "unhandled local login server error: %s", err)
  101. }
  102. code, hasCode := q.values["code"]
  103. if !hasCode {
  104. return errors.Wrap(errdefs.ErrLoginFailed, "no login code")
  105. }
  106. data := url.Values{
  107. "grant_type": []string{"authorization_code"},
  108. "client_id": []string{clientID},
  109. "code": code,
  110. "scope": []string{scopes},
  111. "redirect_uri": []string{redirectURL},
  112. }
  113. token, err := login.apiHelper.queryToken(data, "organizations")
  114. if err != nil {
  115. return errors.Wrapf(errdefs.ErrLoginFailed, "access token request failed: %s", err)
  116. }
  117. bits, statusCode, err := login.apiHelper.queryAuthorizationAPI(authorizationURL, fmt.Sprintf("Bearer %s", token.AccessToken))
  118. if err != nil {
  119. return errors.Wrapf(errdefs.ErrLoginFailed, "check auth failed: %s", err)
  120. }
  121. switch statusCode {
  122. case http.StatusOK:
  123. var t tenantResult
  124. if err := json.Unmarshal(bits, &t); err != nil {
  125. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to unmarshal tenant: %s", err)
  126. }
  127. if len(t.Value) < 1 {
  128. return errors.Wrap(errdefs.ErrLoginFailed, "could not find azure tenant")
  129. }
  130. tID := t.Value[0].TenantID
  131. tToken, err := login.refreshToken(token.RefreshToken, tID)
  132. if err != nil {
  133. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to refresh token: %s", err)
  134. }
  135. loginInfo := TokenInfo{TenantID: tID, Token: tToken}
  136. if err := login.tokenStore.writeLoginInfo(loginInfo); err != nil {
  137. return errors.Wrapf(errdefs.ErrLoginFailed, "could not store login info: %s", err)
  138. }
  139. default:
  140. return errors.Wrapf(errdefs.ErrLoginFailed, "unable to login status code %d: %s", statusCode, bits)
  141. }
  142. }
  143. return nil
  144. }
  145. func getTokenStorePath() string {
  146. cliPath, _ := cli.AccessTokensPath()
  147. return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename)
  148. }
  149. func toOAuthToken(token azureToken) oauth2.Token {
  150. expireTime := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
  151. oauthToken := oauth2.Token{
  152. RefreshToken: token.RefreshToken,
  153. AccessToken: token.AccessToken,
  154. Expiry: expireTime,
  155. TokenType: token.Type,
  156. }
  157. return oauthToken
  158. }
  159. // NewAuthorizerFromLogin creates an authorizer based on login access token
  160. func NewAuthorizerFromLogin() (autorest.Authorizer, error) {
  161. return newAuthorizerFromLoginStorePath(getTokenStorePath())
  162. }
  163. func newAuthorizerFromLoginStorePath(storeTokenPath string) (autorest.Authorizer, error) {
  164. login, err := newAzureLoginServiceFromPath(storeTokenPath, azureAPIHelper{})
  165. if err != nil {
  166. return nil, err
  167. }
  168. oauthToken, err := login.GetValidToken()
  169. if err != nil {
  170. return nil, errors.Wrap(err, "not logged in to azure, you need to run \"docker login azure\" first")
  171. }
  172. token := adal.Token{
  173. AccessToken: oauthToken.AccessToken,
  174. Type: oauthToken.TokenType,
  175. ExpiresIn: json.Number(strconv.Itoa(int(time.Until(oauthToken.Expiry).Seconds()))),
  176. ExpiresOn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(date.UnixEpoch()).Seconds()))),
  177. RefreshToken: "",
  178. Resource: "",
  179. }
  180. return autorest.NewBearerAuthorizer(&token), nil
  181. }
  182. // GetValidToken returns an access token. Refresh token if needed
  183. func (login AzureLoginService) GetValidToken() (oauth2.Token, error) {
  184. loginInfo, err := login.tokenStore.readToken()
  185. if err != nil {
  186. return oauth2.Token{}, err
  187. }
  188. token := loginInfo.Token
  189. if token.Valid() {
  190. return token, nil
  191. }
  192. tenantID := loginInfo.TenantID
  193. token, err = login.refreshToken(token.RefreshToken, tenantID)
  194. if err != nil {
  195. return oauth2.Token{}, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.")
  196. }
  197. err = login.tokenStore.writeLoginInfo(TokenInfo{TenantID: tenantID, Token: token})
  198. if err != nil {
  199. return oauth2.Token{}, err
  200. }
  201. return token, nil
  202. }
  203. func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauth2.Token, error) {
  204. data := url.Values{
  205. "grant_type": []string{"refresh_token"},
  206. "client_id": []string{clientID},
  207. "scope": []string{scopes},
  208. "refresh_token": []string{currentRefreshToken},
  209. }
  210. token, err := login.apiHelper.queryToken(data, tenantID)
  211. if err != nil {
  212. return oauth2.Token{}, err
  213. }
  214. return toOAuthToken(token), nil
  215. }
  216. func openbrowser(url string) {
  217. var err error
  218. switch runtime.GOOS {
  219. case "linux":
  220. err = exec.Command("xdg-open", url).Start()
  221. case "windows":
  222. err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
  223. case "darwin":
  224. err = exec.Command("open", url).Start()
  225. default:
  226. err = fmt.Errorf("unsupported platform")
  227. }
  228. if err != nil {
  229. log.Fatal(err)
  230. }
  231. }