OpenShift Cluster Console 登录源码解析
Jun 16, 2019 14:30 · 1831 words · 4 minute read
源码: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_id
和 redirect_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 只需要坚定地做一个信息的搬运工,告知用户授权的结果就行了。