OpenShift Cluster Console 登录源码解析

Jun 16, 2019 14:30 · 1831 words · 4 minute read OpenShift OAuth2 Golang

源码:https://github.com/openshift/console

需要先切换到 v3.11.0 分支!

登录的函数定义在 pkg/auth/auth.go 文件中:

// LoginFunc redirects to the OIDC provider for user login.
func (a *Authenticator) LoginFunc(w http.ResponseWriter, r *http.Request) {
    var randData [4]byte
    if _, err := io.ReadFull(rand.Reader, randData[:]); err != nil {
        panic(err)
    }
    state := hex.EncodeToString(randData[:])

    cookie := http.Cookie{
        Name:     stateCookieName,
        Value:    state,
        HttpOnly: true,
        Secure:   a.secureCookies,
    }
    http.SetCookie(w, &cookie)
    http.Redirect(w, r, a.getOAuth2Config().AuthCodeURL(state), http.StatusSeeOther)
}

重定向对应了 OAuth2 的第一个步骤(应用程序请求授权):

// vendor/golang.org/x/oauth2/oauth2.go
// Opts may include AccessTypeOnline or AccessTypeOffline, as well
// as ApprovalForce.
// It can also be used to pass the PKCE challenge.
// See https://www.oauth.com/oauth2-servers/pkce/ for more info.
func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string {
    var buf bytes.Buffer
    buf.WriteString(c.Endpoint.AuthURL)
    v := url.Values{
        "response_type": {"code"},
        "client_id":     {c.ClientID},
    }
    if c.RedirectURL != "" {
        v.Set("redirect_uri", c.RedirectURL)
    }
    if len(c.Scopes) > 0 {
        v.Set("scope", strings.Join(c.Scopes, " "))
    }
    if state != "" {
        // TODO(light): Docs say never to omit state; don't allow empty.
        v.Set("state", state)
    }
    for _, opt := range opts {
        opt.setValue(v)
    }
    if strings.Contains(c.Endpoint.AuthURL, "?") {
        buf.WriteByte('&')
    } else {
        buf.WriteByte('?')
    }
    buf.WriteString(v.Encode())
    return buf.String()
}

Cluster Console 中使用了授权码方式,客户端 ID 和重定向 URL 已经事先和 OAuth2 提供方(k8s)协商好了,在通过 openshift-ansible 部署的情景中,client_idredirect_uri 是从配置文件 /var/console-config/console-config.yaml 中拿到的。 随后就将登陆请求重定向给 OAuth2 提供方。

然后 OAuth2 提供方会将授权结果通过回调重定向 URL 的方式通知 Cluster Console:

// CallbackFunc handles OAuth2 callbacks and code/token exchange.
// Requests with unexpected params are redirected to the root route.
func (a *Authenticator) CallbackFunc(fn func(loginInfo LoginJSON, successURL string, w http.ResponseWriter)) func(w http.ResponseWriter, r *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        q := r.URL.Query()
        qErr := q.Get("error")
        code := q.Get("code")
        urlState := q.Get("state")

        cookieState, err := r.Cookie(stateCookieName)
        if err != nil {
            log.Errorf("failed to parse state cookie: %v", err)
            a.redirectAuthError(w, errorMissingState, err)
            return
        }

        // Lack of both `error` and `code` indicates some other redirect with no params.
        if qErr == "" && code == "" {
            http.Redirect(w, r, a.errorURL, http.StatusSeeOther)
            return
        }

        if code == "" {
            log.Infof("missing auth code in query param")
            a.redirectAuthError(w, errorMissingCode, nil)
            return
        }

        if urlState != cookieState.Value {
            log.Errorf("State in url does not match State cookie")
            a.redirectAuthError(w, errorInvalidState, nil)
            return
        }
        ctx := oidc.ClientContext(context.TODO(), a.clientFunc())
        oauthConfig, lm := a.authFunc() // 需要查看 Authenticator 的定义
        token, err := oauthConfig.Exchange(ctx, code)
        if err != nil {
            log.Infof("unable to verify auth code with issuer: %v", err)
            a.redirectAuthError(w, errorInvalidCode, err)
            return
        }

        ls, err := lm.login(w, token)
        if err != nil {
            log.Errorf("error constructing login state: %v", err)
            a.redirectAuthError(w, errorInternal, nil)
            return
        }

        log.Infof("oauth success, redirecting to: %q", a.successURL)
        fn(ls.toLoginJSON(), a.successURL, w)
    }
}

如果授权成功,就可以拿到授权码。看一下 Authenticator 认证器对象的定义:

// pkg/auth/auth.go
type Authenticator struct {
    authFunc func() (*oauth2.Config, loginMethod)

    clientFunc func() *http.Client

    // userFunc returns the User associated with the cookie from a request.
    // This is not part of loginMethod to avoid creating an unnecessary
    // HTTP client for every call.
    userFunc func(*http.Request) (*User, error)

    errorURL      string
    successURL    string
    cookiePath    string
    refererURL    *url.URL
    secureCookies bool
}

找出 userFunc() 方法的实现。这个文件中有一个 NewAuthenticator() 函数,就是 Authenticator 的构造函数:

// pkg/auth/auth.go
func NewAuthenticator(ctx context.Context, c *Config) (*Authenticator, error) {
    // ...
    a.authFunc = func() (*oauth2.Config, loginMethod) {
        // rebuild non-pointer struct each time to prevent any mutation
        baseOAuth2Config := oauth2.Config{
            ClientID:     c.ClientID,
            ClientSecret: c.ClientSecret,
            RedirectURL:  c.RedirectURL,
            Scopes:       c.Scope,
            Endpoint:     fallbackEndpoint,
        }

        currentEndpoint, currentLoginMethod, errAuthSource := authSourceFunc()
        if errAuthSource != nil {
            log.Errorf("failed to get latest auth source data: %v", errAuthSource)
            return &baseOAuth2Config, fallbackLoginMethod
        }

        baseOAuth2Config.Endpoint = currentEndpoint
        return &baseOAuth2Config, currentLoginMethod
    }
}

authFunc 会返回 OAuth2 的配置,也就是 ClientID、ClientSecret 等等。而 loginMethod 是一个接口类型,在 pkg/auth/auth.go 190行:

// pkg/auth/auth.go
case AuthSourceOpenShift:
    a.userFunc = getOpenShiftUser
    authSourceFunc = func() (oauth2.Endpoint, loginMethod, error) {
        // Use the k8s CA for OAuth metadata discovery.
        // Don't include system roots when talking to the API server.
        k8sClient, errK8Client := newHTTPClient(c.K8sCA, false)
        if errK8Client != nil {
            return oauth2.Endpoint{}, nil, errK8Client
        }

        return newOpenShiftAuth(ctx, &openShiftConfig{
            k8sClient:     k8sClient,
            oauthClient:   a.clientFunc(),
            issuerURL:     c.IssuerURL,
            cookiePath:    c.CookiePath,
            secureCookies: c.SecureCookies,
        })
    }

这里的 switch 为什么会选中 cast AuthSourceOpenShift,是因为只要应用程序在启动时读取了 配置文件 /var/console-config/console-config.yaml,就会默认为 AuthSourceOpenShift

找到 newOpenShiftAuth() 函数的定义:

// pkg/auth/auth_openshift.go
func newOpenShiftAuth(ctx context.Context, c *openShiftConfig) (oauth2.Endpoint, *openShiftAuth, error) {
    // Use metadata discovery to determine the OAuth2 token and authorization URL.
    // https://docs.openshift.com/container-platform/3.9/architecture/additional_concepts/authentication.html#oauth-server-metadata
    wellKnownURL := strings.TrimSuffix(c.issuerURL, "/") + "/.well-known/oauth-authorization-server"

    req, err := http.NewRequest(http.MethodGet, wellKnownURL, nil)
    if err != nil {
        return oauth2.Endpoint{}, nil, err
    }

    resp, err := c.k8sClient.Do(req.WithContext(ctx))
    if err != nil {
        return oauth2.Endpoint{}, nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode/100 != 2 {
        return oauth2.Endpoint{}, nil, fmt.Errorf("discovery through endpoint %s failed: %s",
            wellKnownURL, resp.Status)
    }

    var metadata struct {
        Issuer string `json:"issuer"`
        Auth   string `json:"authorization_endpoint"`
        Token  string `json:"token_endpoint"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
        return oauth2.Endpoint{}, nil, fmt.Errorf("discovery through endpoint %s failed to decode body: %v",
            wellKnownURL, err)
    }

    if err := validateAbsURL(metadata.Issuer); err != nil {
        return oauth2.Endpoint{}, nil, err
    }

    if err := validateAbsURL(metadata.Auth); err != nil {
        return oauth2.Endpoint{}, nil, err
    }

    if err := validateAbsURL(metadata.Token); err != nil {
        return oauth2.Endpoint{}, nil, err
    }

    // Make sure we can talk to the issuer endpoint.
    req, err = http.NewRequest(http.MethodHead, metadata.Issuer, nil)
    if err != nil {
        return oauth2.Endpoint{}, nil, err
    }

    resp, err = c.oauthClient.Do(req.WithContext(ctx))
    if err != nil {
        return oauth2.Endpoint{}, nil, fmt.Errorf("request to OAuth issuer endpoint %s failed: %v",
            metadata.Token, err)
    }
    defer resp.Body.Close()

    // Special page on the integrated OAuth server for requesting a token.
    // TODO: We will need to implement this directly console to support external OAuth servers.
    requestTokenURL := proxy.SingleJoiningSlash(metadata.Token, "/request")
    kubeAdminLogoutURL := proxy.SingleJoiningSlash(metadata.Issuer, "/logout")
    return oauth2.Endpoint{
            AuthURL:  metadata.Auth,
            TokenURL: metadata.Token,
        }, &openShiftAuth{
            c.cookiePath,
            c.secureCookies,
            SpecialAuthURLs{
                requestTokenURL,
                kubeAdminLogoutURL,
            },
        }, nil
}

issuerURL 是请求访问令牌签发者的 URL,这里就是 Kubernetes Endpoint,userAuthOIDCIssuerURL = k8sEndpoint,拿到真正的访问令牌签发者。

兜了一大圈再回到回调方法 CallbackFunc()

// pkg/auth/auth.go
token, err := oauthConfig.Exchange(ctx, code)
if err != nil {
    log.Infof("unable to verify auth code with issuer: %v", err)
    a.redirectAuthError(w, errorInvalidCode, err)
    return
}

Exchange() 方法实质上就是用授权码去访问令牌签发者那边兑换访问令牌(access token),对应了 OAuth2 流程中的第三步。再往下走:

ls, err := lm.login(w, token)
if err != nil {
    log.Errorf("error constructing login state: %v", err)
    a.redirectAuthError(w, errorInternal, nil)
    return
}

这里的 login() 方法是应用程序内部的“登陆”,而 loginMethod 是一个接口类型,在构建认证器的时候就已经被实现了。

// pkg/auth/auth_openshift.go
func (o *openShiftAuth) login(w http.ResponseWriter, token *oauth2.Token) (*loginState, error) {
    if token.AccessToken == "" {
        return nil, fmt.Errorf("token response did not contain an access token %#v", token)
    }
    ls := &loginState{
        // Not clear if there's another way to fill in information like the user's name.
        rawToken: token.AccessToken,
    }

    expiresIn := (time.Hour * 24).Seconds()
    if !token.Expiry.IsZero() {
        expiresIn = token.Expiry.Sub(time.Now()).Seconds()
    }

    // NOTE: In Tectonic, we previously had issues with tokens being bigger than
    // cookies can handle. Since OpenShift doesn't store groups in the token, the
    // token can't grow arbitrarily big, so we assume it will always fit in a cookie
    // value.
    //
    // NOTE: in the future we'll have to avoid the use of cookies. This should likely switch to frontend
    // only logic using the OAuth2 implicit flow.
    // https://tools.ietf.org/html/rfc6749#section-4.2
    cookie := http.Cookie{
        Name:     openshiftSessionCookieName,
        Value:    ls.rawToken,
        MaxAge:   int(expiresIn),
        HttpOnly: true,
        Path:     o.cookiePath,
        Secure:   o.secureCookies,
    }

    http.SetCookie(w, &cookie)
    return ls, nil
}

先实例化了一个 loginState 对象,并且这个将这个对象与访问令牌绑定。这里直接将访问令牌与 openshift-session-token 组成一个键值对返回给浏览器(Web Console)。至此登录完成。

我们看到用户在登录时 Cluster Console 并没有校验用户名与密码,这是 OAuth2 提供方做的事,而 Cluster Console 只需要坚定地做一个信息的搬运工,告知用户授权的结果就行了。