跳转至

DexIdP架构

dex-storage

安全起见,dexserver 签发的 id_token 有效期通常不会太长,这就需要 dexclient 凭借 Token 中的 refresh_token 隔段时间重新换取新的 Token,并通过某种机制将新 Token 中的 id_token 重新发回浏览器端保存

以 refresh_token 重新换取新的 Token,dex 需要持久化保存数据来执行各种各样的任务例如 track refresh tokens、preventing replays、and rotating keys

dexserver 在运行时跟踪 refresh_token、auth_code、keys、password 等,还要存储 connectors、认证请求等信息,因此需要将这些状态保存下来

并且 storage 也提供了对存储数据增删改查的接口

dex 提供了多种存储方案,如

  • etcd
  • CRDs
  • SQLite3
  • Postgres
  • MySQL
  • memory

dexserver 会根据项目情况配置一个合适的 Storage,用以安全可靠地保存 refresh_token、auth_code、keys、password 等的状态,要考虑这个 Storage 实现方案的性能、稳定性、高可用性等多个因素

storage 段的配置的是 dexserver 的配置文件中进行存设置

源码:https://github.com/dexidp/dex/blob/v2.35.3/storage/storage.go#L358-L372

// Connector is an object that contains the metadata about connectors used to login to Dex.
type Connector struct {
    // ID that will uniquely identify the connector object.
    ID string `json:"id"`
    // The Type of the connector. E.g. 'oidc' or 'ldap'
    Type string `json:"type"`
    // The Name of the connector that is used when displaying it to the end user.
    Name string `json:"name"`
    // ResourceVersion is the static versioning used to keep track of dynamic configuration
    // changes to the connector object made by the API calls.
    ResourceVersion string `json:"resourceVersion"`
    // Config holds all the configuration information specific to the connector type. Since there
    // no generic struct we can use for this purpose, it is stored as a byte stream.
    //
    // NOTE: This is a bug. The JSON tag should be `config`.
    // However, fixing this requires migrating Kubernetes objects for all previously created connectors,
    // or making Dex reading both tags and act accordingly.
    Config []byte `json:"email"`
}

源码:https://github.com/dexidp/dex/blob/v2.35.3/storage/sql/crud.go#L765-L783

func (c *conn) CreateConnector(connector storage.Connector) error {
    _, err := c.Exec(`
        insert into connector (
            id, type, name, resource_version, config
        )
        values (
            $1, $2, $3, $4, $5
        );
    `,
        connector.ID, connector.Type, connector.Name, connector.ResourceVersion, connector.Config,
    )
    if err != nil {
        if c.alreadyExistsCheck(err) {
            return storage.ErrAlreadyExists
        }
        return fmt.Errorf("insert connector: %v", err)
    }
    return nil
}

源码:https://github.com/dexidp/dex/blob/v2.35.3/storage/kubernetes/storage.go#L259-L261

const (
    resourceAuthCode        = "authcodes"
    resourceAuthRequest     = "authrequests"
    resourceClient          = "oauth2clients"
    resourceRefreshToken    = "refreshtokens"
    resourceKeys            = "signingkeies" // Kubernetes attempts to pluralize.
    resourcePassword        = "passwords"
    resourceOfflineSessions = "offlinesessionses" // Again attempts to pluralize.
    resourceConnector       = "connectors"
    resourceDeviceRequest   = "devicerequests"
    resourceDeviceToken     = "devicetokens"
)

func (cli *client) CreateConnector(c storage.Connector) error {
    return cli.post(resourceConnector, cli.fromStorageConnector(c))
}

并且 dex-storage 存储 connectors 等数据之外,还有存储认证请求的接口,也就是 dex-server 每向后端认证一次,该认证请求会备份到 dex-storage 中

后端在认证成功后会返回 IDtoken,并存储在 dex-storage 中,其中包含了用户的信息

源码:https://github.com/dexidp/dex/blob/v2.35.3/storage/storage.go#L175-L184

// Claims represents the ID Token claims supported by the server.
type Claims struct {
    UserID            string
    Username          string
    PreferredUsername string
    Email             string
    EmailVerified     bool

    Groups []string
}

dex-server

在 dex 服务端配置允许登录的 dex 客户端:staticClients 段配置的是该 dexserver 允许接入的 dexclient(第三方应用) 信息,这个要跟 dexclient 那边的配置一致

在 dex-server 的配置文件中会设置 dex 存储链接方式和 connector 选项

issuer: http://127.0.0.1:5556/dex
storage:
  type: sqlite3
  config:
    file: examples/dex.db
web:
  http: 0.0.0.0:5556

connectors:
  - type: ldap
    name: OpenLDAP
    id: ldap
    config:
      host: localhost:389
      insecureNoSSL: true
      bindDN: cn=admin,dc=example,dc=org
      bindPW: admin
      usernamePrompt: Email Address
      userSearch:
        baseDN: ou=People,dc=example,dc=org
        filter: "(objectClass=person)"
        username: mail
        idAttr: DN
        emailAttr: mail
        nameAttr: cn
      groupSearch:
        baseDN: ou=Groups,dc=example,dc=org
        filter: "(objectClass=groupOfNames)"
        userMatchers:
          - userAttr: DN
            groupAttr: member
        nameAttr: cn

staticClients:
  - id: example-app
    redirectURIs:
      - "http://127.0.0.1:5555/callback"
    name: "Example App"
    secret: ZXhhbXBsZS1hcHAtc2VjcmV0

dex-server认证流程:

(1)解析 oauth2-client 发来的 http 请求,解析为 AuthRequest 结构体

(2)把 AuthRequest 请求备份到 dex-storage

(3)根据请求中的 connector_id,找出 dex-storage 中的该 connector 的具体信息。检索 dex-storage 存储中的连接器对象。 该列表包括 ConfigMap 中定义的静态连接器和从存储中检索的动态连接器

(4)根据 connector 的信息进行登录认证

源码:https://github.com/dexidp/dex/blob/v2.35.3/server/handlers.go#L128-L187

// handleAuthorization handles the OAuth2 auth endpoint.
func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
    // Extract the arguments
    if err := r.ParseForm(); err != nil {
        s.logger.Errorf("Failed to parse arguments: %v", err)

        s.renderError(r, w, http.StatusBadRequest, err.Error())
        return
    }

    connectorID := r.Form.Get("connector_id")

    connectors, err := s.storage.ListConnectors()
    if err != nil {
        s.logger.Errorf("Failed to get list of connectors: %v", err)
        s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.")
        return
    }

    // We don't need connector_id any more
    r.Form.Del("connector_id")

    // Construct a URL with all of the arguments in its query
    connURL := url.URL{
        RawQuery: r.Form.Encode(),
    }

    // Redirect if a client chooses a specific connector_id
    if connectorID != "" {
        for _, c := range connectors {
            if c.ID == connectorID {
                connURL.Path = s.absPath("/auth", url.PathEscape(c.ID))
                http.Redirect(w, r, connURL.String(), http.StatusFound)
                return
            }
        }
        s.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector")
        return
    }

    if len(connectors) == 1 && !s.alwaysShowLogin {
        connURL.Path = s.absPath("/auth", url.PathEscape(connectors[0].ID))
        http.Redirect(w, r, connURL.String(), http.StatusFound)
    }

    connectorInfos := make([]connectorInfo, len(connectors))
    for index, conn := range connectors {
        connURL.Path = s.absPath("/auth", url.PathEscape(conn.ID))
        connectorInfos[index] = connectorInfo{
            ID:   conn.ID,
            Name: conn.Name,
            Type: conn.Type,
            URL:  template.URL(connURL.String()),
        }
    }

    if err := s.templates.login(r, w, connectorInfos); err != nil {
        s.logger.Errorf("Server template error: %v", err)
    }
}

dex-client

dex-client 首先是根据一系列参数构造出 oidc.Provideroidc.IDTokenVerifier,后面获取认证系统的跳转地址、获取 id_token、校验 id_token 都会用到

第三方应用需要编写 dex-client 端的代码需要和 dex-server 进行交互,流程为

(1)服务端配置 dex-client 的信息,只有该 dex-client 信息已经在 dex-server 中配置,相应的 dex-client 才能进行交互

staticClients:
  - id: example-app
    redirectURIs:
      - "http://127.0.0.1:5555/callback"
    name: "Example App"
    secret: ZXhhbXBsZS1hcHAtc2VjcmV0

(2)用 dex-server 端配置的 issuer URL,在第三方应用(client-app)中初始化 OIDC 身份验证服务

provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
    return fmt.Errorf("failed to query provider %q: %v", issuerURL, err)
}

(3)配置 OAuth2 的客户端配置

func (a *app) oauth2Config(scopes []string) *oauth2.Config {
    return &oauth2.Config{
        ClientID:     a.clientID,
        ClientSecret: a.clientSecret,
        Endpoint:     a.provider.Endpoint(),
        Scopes:       scopes,
        RedirectURL:  a.redirectURI,
    }
}

示例:

// Configure the OAuth2 config with the client values.
oauth2Config := oauth2.Config{
    // client_id and client_secret of the client.
    ClientID:     "example-app",
    ClientSecret: "example-app-secret",

    // The redirectURL.
    RedirectURL: "http://127.0.0.1:5555/callback",

    // Discovery returns the OAuth2 endpoints.
    Endpoint: provider.Endpoint(),

    // "openid" is a required scope for OpenID Connect flows.
    //
    // Other scopes, such as "groups" can be requested.
    Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
}

(4)创建 ID token 的解析器

// Create an ID token parser.
idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: "example-app"})

(5)然后,客户端发送 http 请求,请求中包含(client_id 、CA、connector_id 等)HTTP 服务器应将未经身份验证的用户重定向到 dex,dex 服务端验证 dex-client 后开始处理用户登录认证

```
The HTTP server should then redirect unauthenticated users to dex to initialize the OAuth2 flow.
```
// handleRedirect is used to start an OAuth2 flow with the dex server.
func handleRedirect(w http.ResponseWriter, r *http.Request) {
    state := newState()
    http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}

(7)dex 验证用户的身份后,它将使用可以交换 ID 令牌的代码将用户重定向回客户端应用程序。 然后,可以通过上面创建的验证程序来解析 ID 令牌

func handleOAuth2Callback(w http.ResponseWriter, r *http.Request) {
    state := r.URL.Query().Get("state")

    // Verify state.

    oauth2Token, err := oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
    if err != nil {
        // handle error
    }

    // Extract the ID Token from OAuth2 token.
    rawIDToken, ok := oauth2Token.Extra("id_token").(string)
    if !ok {
        // handle missing token
    }

    // Parse and verify ID Token payload.
    idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
    if err != nil {
        // handle error
    }

    // Extract custom claims.
    var claims struct {
        Email    string   `json:"email"`
        Verified bool     `json:"email_verified"`
        Groups   []string `json:"groups"`
    }
    if err := idToken.Claims(&claims); err != nil {
        // handle error
    }
}

connector

dex-server 端 connector 的信息,存储在 static 静态文件和动态的 dex-storage 中

决定了 dex-server 对接的 OIDC 的认证方案的配置