搭建OIDC Provider,以Golang为例

12 篇文章 0 订阅

搭建OIDC Provider,以Golang为例

1 需求

结合对OIDC:https://blog.csdn.net/weixin_45747080/article/details/131810562的理解,我尝试自己搭建OIDC的demo。在搭建demo之前,我需要先确定我想要实现成什么样子。以上文提到的https://blog.csdn.net/weixin_45747080/article/details/131303150为例,我想要手动来实现Github授权以及用户认证的服务,此时Github就是作为一个OIDC Provider(以下简称“OP”)。同时我将手动实现第三方应用程序来作为OP的Relying Party(受OP信赖的客户端,以下简称“RP”)。

在Github注册然后登录用户后,我们就能在我的Github里创建和查看自己的Repository(代码仓库,以下简称“Repo”),同时我有两个App,一个叫Gitee,Gitlab,这两个App实现了能够访问用Github登录的用户的Repo。

Tips

这个需求非常实用,目前Gitee也有能够直接复制Github的仓库到Gitee这个功能。

2 功能实现

所以我现在要手动实现:

  1. Github的Repo的创建和查看功能
  2. 用户在登录Gitee的时候能够使用Github登录,并且访问该用户存放于Github的Repo
  3. 用户在登录Gitlab的时候能够使用Github登录,并且访问该用户存放于Github的Repo
  4. Github作为OIDC Provider,发起授权并且认证用户。

Tips

这里所提到的Github、Gitee和Gitlab并不是真正的“它们”,而是用我手动实现类似它们的上述功能,方便理解。

3 工作流程

在这里插入图片描述

  1. 已在Github注册的用户在登录Gitlab的时候选择以Github登录。
  2. 此时Gitlab向Github的OP发起请求要求用户登录Github并且确认访问的范围(scope)。
  3. OP验证用户成功后返回access_tokenid_token给Gitlab。
  4. Gitlab解析id_token以获得用户的信息并进行存储。

Gitee同理。

4 选用库

用Golang实现的话,Golang有现成的实现了OIDC的库:dex。感谢Authing提供参考:https://zhuanlan.zhihu.com/p/118037137,文章里有提到不同的OIDC Provider的实现,如在node上的实现,在Golang上的实现,在Python上的实现等。所以这里我选用dex作为OIDC的Provider。

5 Token的授权方式

demo中授权的方式采用授权码模式,即向OP发起授权请求的response_typecode

6 Provider的Endpoint

Provider会暴露一些常用的接口:如授权接口,token接口,用户信息接口。一般通过访问

{$issuer}/.well-known/openid-configuration

即可查看。

  • 授权接口:{$issuer}/auth

访问该接口的时候需携带ClientID,ClientSecret,ResponseType,Scope。OP会返回code。

  • token接口:{$issuer}/token

访问该接口的时候需携带code,用于和OP交换token。

  • 用户信息接口:{$issuer}/userinfo

访问该接口需要携带accessToken,用户获取EU的个人信息。

7 Repo的服务

首先我们需要一个resource的服务用于存放Repo。

storage

我们需要选用持久化来存储Repo同时对Repo进行增加和查看(这里为了演示方便就直接采用一次性的HashMap来暂存数据,服务重启则HashMap被清空):

type repo struct {
	Name      string
	CreatedBy string
}

type Storage struct {
	set   map[string]struct{}
	repos []*repo
}

var instance *Storage // single instance

func New() *Storage {
	if instance == nil {
		fmt.Println("create a new storage")
		instance = &Storage{
			set:   make(map[string]struct{}),
			repos: make([]*repo, 0),
		}
		return instance
	}

	fmt.Println("storage already exists")
	return instance
}

func (s *Storage) AddRepo(name, subject string) bool {
	if _, ok := s.set[name]; ok {
		return false
	}

	r := &repo{
		Name:      name,
		CreatedBy: subject,
	}
	s.repos = append(s.repos, r)
	s.set[name] = struct{}{}
	return true
}

func (s *Storage) GetRepoBySubject(subject string) []*repo {
	var res []*repo
	for _, r := range s.repos {
		if r.CreatedBy == subject {
			res = append(res, r)
		}
	}
	return res
}

func (s *Storage) AllRepo() []*repo {
	return s.repos
}

有三个对数据的操作,分别是添加Repo根据subject查询Repo列出所有的Repo

APIs

向外暴露3个API:添加Repo查看用户自己创建的Repo列出所有Repo

func AddRepo(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")
	if name = strings.TrimSpace(name); name == "" {
		http.Error(w, fmt.Sprintf("invalid repo name: empty repo name"), http.StatusBadRequest)
		return
	}

	ok := s.AddRepo(name, ui.Subject)
	fmt.Fprintf(w, "%t", ok)
}

func MyRepo(w http.ResponseWriter, r *http.Request) {
	books := s.GetRepoBySubject(ui.Subject)
	bytes, _ := json.Marshal(books)
	w.Write(bytes)
}

func AllRepo(w http.ResponseWriter, r *http.Request) {
	// for admin

	books := s.AllRepo()
	bytes, _ := json.Marshal(books)
	w.Write(bytes)
}

其中添加Repo和查看自己的Repo需要将用户创建的资源与用户进行绑定,首先需要拿到access_token(这里是从http header中取的),再用access_token去userinfo_endpoint拿到用户的个人信息。

Tips

OAuth2.0中可以通过访问userinfo_endpoint来获取用户的个人信息,并且个人信息中的subject就能唯一标识一个用户,所以这里将subject与用户创建的资源进行绑定。

从header中获得access_token:

func tokenFromHeader(header http.Header) (typ string, token string, err error) {
	token = header.Get("Authorization")
	splits := strings.SplitN(token, " ", 2)
	if len(splits) < 2 {
		return "", "", fmt.Errorf("invalid authorization: empty authorization")
	}

	typ = splits[0]
	token = splits[1]
	if typ != "Bearer" && typ != "bearer" {
		return "", "", fmt.Errorf("invalid authorization type: %s", typ)
	}

	return typ, token, nil
}

用access_token访问userinfo_endpoint:

func getUserInfo(typ, accessToken string) (*userinfo.Userinfo, error) {
	client := http.DefaultClient

	// get the configurations from {issuer}/.well-known/openid-configuration
	u, _ := url.Parse(OidcIssuer)
	u.Path = filepath.Join(u.Path, "/.well-known/openid-configuration")
	res, err := client.Get(u.String())
	if err != nil {
		return nil, fmt.Errorf("list openid-configuration from %s failed: %s", u.String(), err)
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		msg, _ := ioutil.ReadAll(res.Body)
		return nil, fmt.Errorf("list openid-configuration from %s failed: %s", u.String(), msg)
	}

	var configurations map[string]interface{}
	if err = json.NewDecoder(res.Body).Decode(&configurations); err != nil {
		return nil, fmt.Errorf("parse configurations from %s failed: %s", u.String(), err)
	}

	// get the userinfo from {issuer}/{userinfo_endpoint}
	userinfoEndpoint := configurations["userinfo_endpoint"].(string)
	req, _ := http.NewRequest("GET", userinfoEndpoint, nil)
	req.Header.Set("Authorization", fmt.Sprintf("%s %s", typ, accessToken))
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("get userinfo from %s failed: %s", u.String(), err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		msg, _ := ioutil.ReadAll(resp.Body)
		return nil, fmt.Errorf("get userinfo from %s failed: %s", u.String(), msg)
	}

	var ui userinfo.Userinfo
	if err = json.NewDecoder(resp.Body).Decode(&ui); err != nil {
		return nil, fmt.Errorf("parse userinfo from %s failed: %s", userinfoEndpoint, err)
	}
	return &ui, nil
}

这里先访问{issuer}/.well-known/openid-configuration获得OP所提供的openid-configurations,是以json格式返回的各种endpoint,这里拿到userinfo_endpoint的值,然后通过http Get请求携带access_token向userinfo_endpoint发起访问返回json格式的用户信息。

所以现在将token的获取和用户个人信息的获取加入到APIs中,代码如下:

func AddRepo(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")
	if name = strings.TrimSpace(name); name == "" {
		http.Error(w, fmt.Sprintf("invalid repo name: empty repo name"), http.StatusBadRequest)
		return
	}

	typ, accessToken, err := tokenFromHeader(r.Header)
	if err != nil {
		http.Error(w, fmt.Sprintf("get token from header failed: %s", err), http.StatusUnauthorized)
		return
	}
	ui, err := getUserInfo(typ, accessToken)
	if err != nil {
		http.Error(w, fmt.Sprintf("get userinfo failed: %s", err), http.StatusUnauthorized)
		return
	}

	ok := s.AddRepo(name, ui.Subject)
	fmt.Fprintf(w, "%t", ok)
}

func MyRepo(w http.ResponseWriter, r *http.Request) {
	typ, accessToken, err := tokenFromHeader(r.Header)
	if err != nil {
		http.Error(w, fmt.Sprintf("get token from header failed: %s", err), http.StatusUnauthorized)
		return
	}
	ui, err := getUserInfo(typ, accessToken)
	if err != nil {
		http.Error(w, fmt.Sprintf("get userinfo failed: %s", err), http.StatusUnauthorized)
		return
	}

	books := s.GetRepoBySubject(ui.Subject)
	bytes, _ := json.Marshal(books)
	w.Write(bytes)
}

此时资源服务就能增加和查看用户自己的Repo了。

8 gitee

storage

得益于IDToken,gitee能够在用户使用github的账户登录后得到他在github的信息,还可以存储在自己的数据库里。所以这里用storage来存储Github登录后的该用户的个人信息。

type user struct {
	Subject  string
	Name     string
	Audience string
	Email    string
}

type Storage struct {
	set   map[string]struct{}
	users []*user
}

var instance *Storage

func New() *Storage {
	if instance == nil {
		fmt.Println("create a new storage")
		instance = &Storage{
			set:   make(map[string]struct{}),
			users: make([]*user, 0),
		}
		return instance
	}

	fmt.Println("storage already exists")
	return instance
}

func (s *Storage) AddUser(subject, name, audience, email string) bool {
	if _, ok := s.set[subject]; ok {
		return false
	}

	u := &user{
		Subject:  subject,
		Name:     name,
		Audience: audience,
		Email:    email,
	}
	s.users = append(s.users, u)
	s.set[subject] = struct{}{}

	return true
}

func (s *Storage) AllUser() []*user {
	return s.users
}

OP授权

login

在用户尝试登录后,gitee就会拼凑ClientId,ClientSecret,ResponseType等发送给OP请求获取code。

func Oauth2Config(provider *oidc.Provider) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     ClientID,
		ClientSecret: ClientSecret,
		RedirectURL:  RedirectURL,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "groups"},
	}
}

// Login http redirect to oidc provider
func Login(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	provider, err := oidc.NewProvider(ctx, OidcIssuer)
	if err != nil {
		http.Error(w, fmt.Sprintf("init provider failed: %s", err), http.StatusInternalServerError)
		return
	}

	config := Oauth2Config(provider)
	url := config.AuthCodeURL("state")

	http.Redirect(w, r, url, http.StatusFound)
}
loginCallback

此时OP会带着code发回给回调地址redirect_url。在回调中,用code与OP交换token,然后解析IDToken获取用户个人信息并存储到gitee的数据库中。

// LoginCallback the callback that the oidc provider with call when the user login successfully
func LoginCallback(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	provider, err := oidc.NewProvider(ctx, OidcIssuer)
	if err != nil {
		http.Error(w, fmt.Sprintf("init provider failed: %s", err), http.StatusInternalServerError)
		return
	}

	// exchange token with the server using authorization code
	config := Oauth2Config(provider)
	oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"))
	if err != nil {
		http.Error(w, fmt.Sprintf("exchange token with server failed: %s", err), http.StatusUnauthorized)
		return
	}

	// get rawIDToken with token
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		http.Error(w, fmt.Sprintf("get rawIDToken with token failed"), http.StatusUnauthorized)
		return
	}

	// verify IDToken with idTokenVerifier, the idTokenVerifier is generated by provider
	idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: ClientID})
	idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
	if err != nil {
		http.Error(w, fmt.Sprintf("verify IDToken with oidc provider failed: %s", err), http.StatusUnauthorized)
		return
	}

	var ui userinfo.UserInfo
	if err = idToken.Claims(&ui); err != nil {
		http.Error(w, fmt.Sprintf("parse id token failed: %s", err), http.StatusInternalServerError)
		return
	}
	ui.AccessToken = oauth2Token.AccessToken
	ui.IDToken = rawIDToken

	s.AddUser(ui.Subject, ui.Name, ui.Audience, ui.Email)

	bytes, _ := json.Marshal(&ui)
	w.Write(bytes) // output the userinfo structure as json
}

这里最好将获取到的access_token、refresh_token、expiry、id_token等存入cookie中,方便下次用户登录的时候不需要重新登录。

Tips

此时的IDToken照理说只能在gitee中使用的,该IDToken的aud是gitee。

API

访问用户存储在github的repo:

func ReadMyRepo(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	token, err := auth.GetFromCookie(r)
	if err != nil {
		http.Error(w, fmt.Sprintf("get token from request failed: %s", err), http.StatusUnauthorized)
		return
	}
	accessToken := token.AccessToken
	tokenType := token.TokenType
	rawIDToken := token.IdToken

	provider, err := oidc.NewProvider(ctx, OidcIssuer)
	if err != nil {
		http.Error(w, fmt.Sprintf("init oidc provider failed: %s", err), http.StatusInternalServerError)
		return
	}
	idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: ClientID})
	idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
	if err = idToken.VerifyAccessToken(accessToken); err != nil {
		http.Error(w, fmt.Sprintf("id_token does not match access_token"), http.StatusUnauthorized)
		return
	} // check if id_token matches access_token

	client := http.DefaultClient
	req, _ := http.NewRequest("GET", ResourceMyBook, nil)
	req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, accessToken))
	res, err := client.Do(req) // do get request with Authorization (access_token)
	if err != nil {
		http.Error(w, fmt.Sprintf("get my book from resource failed: %s", err), http.StatusInternalServerError)
		return
	}
	defer res.Body.Close()
	bytes, _ := ioutil.ReadAll(res.Body)
	w.Write(bytes)
}

先从cookie中取出accessToken和IDToken,然后校验并解析IDToken,同时检查accessToken和IDToken是否匹配。provider.Verifier(&oidc.Config{ClientID: ClientID})的目的是校验器需要校验IDToken的aud是否与ClientID匹配,即校验该IDToken是否是发给其他Client的而不是gitee。此时我们拿着登录用户的AccessToken发送Get请求给Repo的服务查看自己Repo的API。

需要注意的是,来自cookie中的token可能会过期,于是需要利用refresh_token来刷新token以保证即使access_token过期了也能刷新access_token去访问资源。

如果access_token过期可以使用refresh_token去获取新的token

if err != nil && strings.Contains(err.Error(), "oidc: token is expired") {
		// use refresh_token to refresh token
		config := auth.Oauth2Config(provider)
		ts := config.TokenSource(ctx, &oauth2.Token{RefreshToken: token.RefreshToken})
		newToken, err := ts.Token()
		if err != nil {
			http.Error(w, fmt.Sprintf("refresh token failed: %s", err), http.StatusInternalServerError)
			return
		}
		auth.SetIntoCookie(w, newToken) // set new token(contains access_token, refresh_token, id_token...) into cookie
		accessToken = newToken.AccessToken
		tokenType = newToken.TokenType
		rawIDToken = newToken.Extra("id_token").(string)
		idToken, _ = idTokenVerifier.Verify(ctx, rawIDToken)
	}

Tips

具体要不要在访问资源的时候检查token是否过期可以根据需求,也可以在前端采用各种策略(如轮询)来检查用户token是否过期,过期即要求用户重新登录,此时的access_token就会是最新的了,访问资源的时候就不需要再重新刷新access_token了。

9 gitlab

gitlab同理,只是同gitee的clientId和clientSecret以及服务启动的端口不同。

10 OIDC Provider

根据dex的说明,启动Provider需要先修改配置文件,在./examples/config-dev.yaml里的staticClients里添加gitee和gitlab两个client:

staticClients:
- id: example-app
  redirectURIs:
  - 'http://127.0.0.1:5555/callback'
  name: 'Example App'
  secret: ZXhhbXBsZS1hcHAtc2VjcmV0
#  - id: example-device-client
#    redirectURIs:
#      - /device/callback
#    name: 'Static Client for Device Flow'
#    public: true
- id: gitee
  secret: gitee-secret
  name: 'Example Gitee'
  redirectURIs:
  - 'http://app1:8080/callback'
- id: gitlab
  secret: gitlab-secret
  name: 'Example Github'
  redirectURIs:
  - 'http://app2:8081/callback'

所以gitee的地址就是http://app1:8080,gitlab的地址就是http://app2:8081

Tips

gitee和gitlab的回调地址分别是http://app1:8080/callbackhttp://app2:8081/callback。这里需要修改本机的host地址,修改127.0.0.1映射到app1app2上。这里这么做的目的是如果不修改的话gitee和gitlab的监听地址分别是http://localhost:8080/callbackhttp://localhost:8081/callback此时由于浏览器cookie的存储策略问题(同一domain不同port仍然共用cookie),这两App就会共用同一个cookie,这显然是不正确的,所以这里需要修改host的映射地址,最终在浏览器中gitee和gitlab就不会共用同一cookie了。

11 测试演示

  1. 登录github

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

  2. 添加Repo

    使用access_token添加名为test、test1、test2、test3的Repo

    在这里插入图片描述

  3. 查看Repo

    使用access_token查看Repo

    在这里插入图片描述

  4. 在gitee使用github登录

    浏览器输入http://app1:8080/login登录在github中注册的用户确认访问范围

    在这里插入图片描述

    此时浏览器的cookie中已经存了该用户的access_token、id_token等。

  5. 在gitee中查看github中的Repo

    浏览器输入http://app1:8080/read

    在这里插入图片描述

    此时该用户就成功在gitee访问到了该用户存在github的Repo。

  6. 同样地,我在gitee登录另外一个账号

  7. 调用gitee的管理员接口就能查看到登录过gitee的用户了

    在这里插入图片描述

至此,便完成了我的需求。在gitee使用github登录,并且存储登录用户的个人信息,同时还可以在gitee直接访问登录用户存储于github的资源。

那么同样地,gitlab也可以使用github登录,也可以存储登录用户的个人信息,同时也可以在gitlab直接访问登录用户存储与github的资源。

仓库地址

https://github.com/FanGaoXS/oidc-demo

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值