搭建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 功能实现
所以我现在要手动实现:
- Github的Repo的创建和查看功能
- 用户在登录Gitee的时候能够使用Github登录,并且访问该用户存放于Github的Repo
- 用户在登录Gitlab的时候能够使用Github登录,并且访问该用户存放于Github的Repo
- Github作为OIDC Provider,发起授权并且认证用户。
Tips
这里所提到的Github、Gitee和Gitlab并不是真正的“它们”,而是用我手动实现类似它们的上述功能,方便理解。
3 工作流程
- 已在Github注册的用户在登录Gitlab的时候选择以Github登录。
- 此时Gitlab向Github的OP发起请求要求用户登录Github并且确认访问的范围(scope)。
- OP验证用户成功后返回
access_token
和id_token
给Gitlab。 - 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_type
是code
。
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/callback
和http://app2:8081/callback
。这里需要修改本机的host地址,修改127.0.0.1映射到app1
和app2
上。这里这么做的目的是如果不修改的话gitee和gitlab的监听地址分别是http://localhost:8080/callback
和http://localhost:8081/callback
此时由于浏览器cookie的存储策略问题(同一domain不同port仍然共用cookie),这两App就会共用同一个cookie,这显然是不正确的,所以这里需要修改host的映射地址,最终在浏览器中gitee和gitlab就不会共用同一cookie了。
11 测试演示
-
登录github
-
添加Repo
使用access_token添加名为test、test1、test2、test3的Repo
-
查看Repo
使用access_token查看Repo
-
在gitee使用github登录
浏览器输入
http://app1:8080/login
登录在github中注册的用户确认访问范围此时浏览器的cookie中已经存了该用户的access_token、id_token等。
-
在gitee中查看github中的Repo
浏览器输入
http://app1:8080/read
此时该用户就成功在gitee访问到了该用户存在github的Repo。
-
同样地,我在gitee登录另外一个账号
-
调用gitee的管理员接口就能查看到登录过gitee的用户了
至此,便完成了我的需求。在gitee使用github登录,并且存储登录用户的个人信息,同时还可以在gitee直接访问登录用户存储于github的资源。
那么同样地,gitlab也可以使用github登录,也可以存储登录用户的个人信息,同时也可以在gitlab直接访问登录用户存储与github的资源。
仓库地址
https://github.com/FanGaoXS/oidc-demo