feat: support OIDC (#893)

* feat: support oidc

* refactor: sso -> oidc

* refactor: add AccessToken

* refactor: change some naming
This commit is contained in:
Yening Qin 2022-03-30 11:01:02 +08:00 committed by GitHub
parent 7b3cb2eb00
commit a67356639b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 317 additions and 0 deletions

View File

@ -121,6 +121,20 @@ Nickname = "cn"
Phone = "mobile"
Email = "mail"
[OIDC]
Enable = false
RedirectURL = "http://n9e.com/callback"
SsoAddr = "http://sso.example.org"
ClientId = ""
ClientSecret = ""
CoverAttributes = true
DefaultRoles = ["Standard"]
[OIDC.Attributes]
Nickname = "nickname"
Phone = "phone_number"
Email = "email"
[Redis]
# address, ip:port
Address = "127.0.0.1:6379"

4
go.mod
View File

@ -3,6 +3,7 @@ module github.com/didi/nightingale/v5
go 1.14
require (
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/fatih/camelcase v1.0.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
@ -20,6 +21,7 @@ require (
github.com/mattn/go-isatty v0.0.12
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc
github.com/pkg/errors v0.9.1
github.com/pquerna/cachecontrol v0.1.0 // indirect
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.26.0
github.com/prometheus/prometheus v2.5.0+incompatible
@ -27,11 +29,13 @@ require (
github.com/toolkits/pkg v1.2.9
github.com/urfave/cli/v2 v2.3.0
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
google.golang.org/genproto v0.0.0-20211007155348-82e027067bd4 // indirect
google.golang.org/grpc v1.41.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gorm.io/driver/mysql v1.1.2
gorm.io/driver/postgres v1.1.1
gorm.io/gorm v1.21.15

9
go.sum
View File

@ -29,6 +29,8 @@ github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
@ -249,6 +251,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc=
github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@ -298,6 +302,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
@ -376,6 +381,7 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQ
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -448,6 +454,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
@ -489,6 +496,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -89,6 +89,14 @@ func (u *User) Add() error {
return Insert(u)
}
func (u *User) Update(selectField interface{}, selectFields ...interface{}) error {
if err := u.Verify(); err != nil {
return err
}
return DB().Model(u).Select(selectField, selectFields).Updates(u).Error
}
func (u *User) UpdateAllFields() error {
if err := u.Verify(); err != nil {
return err

170
src/pkg/oidcc/oidc.go Normal file
View File

@ -0,0 +1,170 @@
package oidcc
import (
"context"
"fmt"
"log"
"time"
"github.com/didi/nightingale/v5/src/storage"
oidc "github.com/coreos/go-oidc"
"github.com/google/uuid"
"github.com/toolkits/pkg/logger"
"golang.org/x/oauth2"
)
type ssoClient struct {
verifier *oidc.IDTokenVerifier
config oauth2.Config
ssoAddr string
callbackAddr string
coverAttributes bool
attributes struct {
username string
nickname string
phone string
email string
}
}
type Config struct {
Enable bool
RedirectURL string
SsoAddr string
ClientId string
ClientSecret string
CoverAttributes bool
Attributes struct {
Nickname string
Phone string
Email string
}
DefaultRoles []string
}
var (
cli ssoClient
)
func Init(cf Config) {
if !cf.Enable {
return
}
cli.ssoAddr = cf.SsoAddr
cli.callbackAddr = cf.RedirectURL
cli.coverAttributes = cf.CoverAttributes
cli.attributes.username = "sub"
cli.attributes.nickname = cf.Attributes.Nickname
cli.attributes.phone = cf.Attributes.Phone
cli.attributes.email = cf.Attributes.Email
provider, err := oidc.NewProvider(context.Background(), cf.SsoAddr)
if err != nil {
log.Fatal(err)
}
oidcConfig := &oidc.Config{
ClientID: cf.ClientId,
}
cli.verifier = provider.Verifier(oidcConfig)
cli.config = oauth2.Config{
ClientID: cf.ClientId,
ClientSecret: cf.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: cf.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "phone"},
}
}
func wrapStateKey(key string) string {
return "n9e_oidc_" + key
}
// Authorize return the sso authorize location with state
func Authorize(redirect string) (string, error) {
state := uuid.New().String()
ctx := context.Background()
err := storage.Redis.Set(ctx, wrapStateKey(state), redirect, time.Duration(300*time.Second)).Err()
if err != nil {
return "", err
}
return cli.config.AuthCodeURL(state), nil
}
func fetchRedirect(ctx context.Context, state string) (string, error) {
return storage.Redis.Get(ctx, wrapStateKey(state)).Result()
}
func deleteRedirect(ctx context.Context, state string) error {
return storage.Redis.Del(ctx, wrapStateKey(state)).Err()
}
// Callback 用 code 兑换 accessToken 以及 用户信息,
func Callback(ctx context.Context, code, state string) (*CallbackOutput, error) {
ret, err := exchangeUser(code)
if err != nil {
return nil, fmt.Errorf("ilegal user:%v", err)
}
ret.Redirect, err = fetchRedirect(ctx, state)
if err != nil {
logger.Debugf("get redirect err:%v code:%s state:%s", code, state, err)
}
err = deleteRedirect(ctx, state)
if err != nil {
logger.Debugf("delete redirect err:%v code:%s state:%s", code, state, err)
}
return ret, nil
}
type CallbackOutput struct {
Redirect string `json:"redirect"`
Msg string `json:"msg"`
AccessToken string `json:"accessToken"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Phone string `yaml:"phone"`
Email string `yaml:"email"`
}
func exchangeUser(code string) (*CallbackOutput, error) {
ctx := context.Background()
oauth2Token, err := cli.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("Failed to exchange token: %s", err)
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("No id_token field in oauth2 token.")
}
idToken, err := cli.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("Failed to verify ID Token: %s", err)
}
data := map[string]interface{}{}
if err := idToken.Claims(&data); err != nil {
return nil, err
}
v := func(k string) string {
if in := data[k]; in == nil {
return ""
} else {
return in.(string)
}
}
return &CallbackOutput{
AccessToken: oauth2Token.AccessToken,
Username: v(cli.attributes.username),
Nickname: v(cli.attributes.nickname),
Phone: v(cli.attributes.phone),
Email: v(cli.attributes.email),
}, nil
}

View File

@ -12,6 +12,7 @@ import (
"github.com/didi/nightingale/v5/src/pkg/httpx"
"github.com/didi/nightingale/v5/src/pkg/ldapx"
"github.com/didi/nightingale/v5/src/pkg/logx"
"github.com/didi/nightingale/v5/src/pkg/oidcc"
"github.com/didi/nightingale/v5/src/storage"
"github.com/didi/nightingale/v5/src/webapi/prom"
)
@ -94,6 +95,7 @@ type Config struct {
Postgres storage.Postgres
Clusters []prom.Options
Ibex Ibex
OIDC oidcc.Config
}
type LabelAndKey struct {

View File

@ -114,6 +114,9 @@ func configRoute(r *gin.Engine, version string) {
pages.POST("/auth/logout", logoutPost)
pages.POST("/auth/refresh", refreshPost)
pages.GET("/auth/redirect", loginRedirect)
pages.GET("/auth/callback", loginCallback)
pages.GET("/metrics/desc", metricsDescGetFile)
pages.POST("/metrics/desc", metricsDescGetMap)

View File

@ -4,12 +4,15 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/toolkits/pkg/ginx"
"github.com/toolkits/pkg/logger"
"github.com/didi/nightingale/v5/src/models"
"github.com/didi/nightingale/v5/src/pkg/oidcc"
"github.com/didi/nightingale/v5/src/webapi/config"
)
@ -136,3 +139,103 @@ func refreshPost(c *gin.Context) {
ginx.NewRender(c, http.StatusUnauthorized).Message("refresh token expired")
}
}
func loginRedirect(c *gin.Context) {
redirect := ginx.QueryStr(c, "redirect", "/")
v, exsits := c.Get("userid")
if exsits {
userid := v.(int64)
user, err := models.UserGetById(userid)
ginx.Dangerous(err)
if user == nil {
ginx.Bomb(200, "user not found")
}
if user.Username != "" { // alread login
ginx.NewRender(c).Data(redirect, nil)
return
}
}
if !config.C.OIDC.Enable {
ginx.NewRender(c).Data("", nil)
return
}
redirect, err := oidcc.Authorize(redirect)
ginx.Dangerous(err)
ginx.NewRender(c).Data(redirect, err)
}
type CallbackOutput struct {
Redirect string `json:"redirect"`
User *models.User `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func loginCallback(c *gin.Context) {
code := ginx.QueryStr(c, "code", "")
state := ginx.QueryStr(c, "state", "")
ret, err := oidcc.Callback(c.Request.Context(), code, state)
if err != nil {
logger.Debugf("sso.callback() get ret %+v error %v", ret, err)
ginx.NewRender(c).Data(CallbackOutput{}, err)
return
}
user, err := models.UserGet("username=?", ret.Username)
ginx.Dangerous(err)
if user != nil {
if config.C.OIDC.CoverAttributes {
user.Nickname = ret.Nickname
user.Email = ret.Email
user.Phone = ret.Phone
user.UpdateAt = time.Now().Unix()
user.Update("email", "nickname", "phone", "update_at")
}
} else {
now := time.Now().Unix()
user = &models.User{
Username: ret.Username,
Password: "******",
Nickname: ret.Nickname,
Phone: ret.Phone,
Email: ret.Email,
Portrait: "",
Roles: strings.Join(config.C.OIDC.DefaultRoles, " "),
RolesLst: config.C.OIDC.DefaultRoles,
Contacts: []byte("{}"),
CreateAt: now,
UpdateAt: now,
CreateBy: "oidc",
UpdateBy: "oidc",
}
// create user from oidc
ginx.Dangerous(user.Add())
}
// set user login state
userIdentity := fmt.Sprintf("%d-%s", user.Id, user.Username)
ts, err := createTokens(config.C.JWTAuth.SigningKey, userIdentity)
ginx.Dangerous(err)
ginx.Dangerous(createAuth(c.Request.Context(), userIdentity, ts))
redirect := "/"
if ret.Redirect != "/login" {
redirect = ret.Redirect
}
ginx.NewRender(c).Data(CallbackOutput{
Redirect: redirect,
User: user,
AccessToken: ts.AccessToken,
RefreshToken: ts.RefreshToken,
}, nil)
}

View File

@ -13,6 +13,7 @@ import (
"github.com/didi/nightingale/v5/src/pkg/httpx"
"github.com/didi/nightingale/v5/src/pkg/ldapx"
"github.com/didi/nightingale/v5/src/pkg/logx"
"github.com/didi/nightingale/v5/src/pkg/oidcc"
"github.com/didi/nightingale/v5/src/storage"
"github.com/didi/nightingale/v5/src/webapi/config"
"github.com/didi/nightingale/v5/src/webapi/prom"
@ -90,6 +91,9 @@ func (a Webapi) initialize() (func(), error) {
// init ldap
ldapx.Init(config.C.LDAP)
// init oidc
oidcc.Init(config.C.OIDC)
// init logger
loggerClean, err := logx.Init(config.C.Log)
if err != nil {