support OIDC protocol

Signed-off-by: hongming <hongming@kubesphere.io>
This commit is contained in:
hongming
2021-09-14 18:06:28 +08:00
parent 4b5b1c64bc
commit 8c5c6a7dee
5 changed files with 199 additions and 30 deletions

View File

@@ -61,6 +61,12 @@ type VerifiedResponse struct {
Claims
}
// Keys hold encryption and signing keys.
type Keys struct {
SigningKey *jose.JSONWebKey
SigningKeyPub *jose.JSONWebKey
}
// Issuer issues token to user, tokens are required to perform mutating requests to resources
type Issuer interface {
// IssueTo issues a token a User, return error if issuing process failed
@@ -68,6 +74,9 @@ type Issuer interface {
// Verify verifies a token, and return a user info if it's a valid token, otherwise return error
Verify(string) (*VerifiedResponse, error)
// Keys hold encryption and signing keys.
Keys() *Keys
}
type Claims struct {
@@ -80,6 +89,14 @@ type Claims struct {
// String value used to associate a Client session with an ID Token, and to mitigate replay attacks.
// The value is passed through unmodified from the Authentication Request to the ID Token.
Nonce string `json:"nonce,omitempty"`
// Scopes can be used to request that specific sets of information be made available as Claim Values.
Scopes []string `json:"scopes,omitempty"`
// End-User's preferred e-mail address.
Email string `json:"email,omitempty"`
// End-User's locale, represented as a BCP47 [RFC5646] language tag.
Locale string `json:"locale,omitempty"`
// Shorthand name by which the End-User wishes to be referred to at the RP,
PreferredUsername string `json:"preferred_username,omitempty"`
// Extra contains the additional information
Extra map[string][]string `json:"extra,omitempty"`
}
@@ -90,7 +107,7 @@ type issuer struct {
// signing access_token and refresh_token
secret []byte
// signing id_token
signKey *jose.JSONWebKey
signKey *Keys
// Token verification maximum time difference
maximumClockSkew time.Duration
}
@@ -114,6 +131,18 @@ func (s *issuer) IssueTo(request *IssueRequest) (string, error) {
if request.Nonce != "" {
claims.Nonce = request.Nonce
}
if request.Email != "" {
claims.Email = request.Email
}
if request.PreferredUsername != "" {
claims.PreferredUsername = request.PreferredUsername
}
if request.Locale != "" {
claims.Locale = request.Locale
}
if len(request.Scopes) > 0 {
claims.Scopes = request.Scopes
}
if request.ExpiresIn > 0 {
claims.ExpiresAt = claims.IssuedAt + int64(request.ExpiresIn.Seconds())
}
@@ -122,8 +151,8 @@ func (s *issuer) IssueTo(request *IssueRequest) (string, error) {
var err error
if request.TokenType == IDToken {
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t.Header[headerKeyID] = s.signKey.KeyID
token, err = t.SignedString(s.signKey.Key)
t.Header[headerKeyID] = s.signKey.SigningKey.KeyID
token, err = t.SignedString(s.signKey.SigningKey.Key)
} else {
token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
}
@@ -175,13 +204,17 @@ func (s *issuer) Verify(token string) (*VerifiedResponse, error) {
return verified, nil
}
func (s *issuer) Keys() *Keys {
return s.signKey
}
func (s *issuer) keyFunc(token *jwt.Token) (i interface{}, err error) {
alg, _ := token.Header[headerAlgorithm].(string)
switch alg {
case jwt.SigningMethodHS256.Alg():
return s.secret, nil
case jwt.SigningMethodRS256.Alg():
return s.signKey.Key, nil
return s.signKey.SigningKey.Key, nil
default:
return nil, fmt.Errorf("unexpect signature algorithm %v", token.Header[headerAlgorithm])
}
@@ -263,11 +296,19 @@ func NewIssuer(options *authentication.Options) (Issuer, error) {
name: options.OAuthOptions.Issuer,
secret: []byte(options.JwtSecret),
maximumClockSkew: options.MaximumClockSkew,
signKey: &jose.JSONWebKey{
Key: signKey,
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
signKey: &Keys{
SigningKey: &jose.JSONWebKey{
Key: signKey,
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
},
SigningKeyPub: &jose.JSONWebKey{
Key: signKey.Public(),
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
},
},
}, nil
}

View File

@@ -87,11 +87,19 @@ func TestNewIssuer(t *testing.T) {
name: options.OAuthOptions.Issuer,
secret: []byte(options.JwtSecret),
maximumClockSkew: options.MaximumClockSkew,
signKey: &jose.JSONWebKey{
Key: signKey,
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
signKey: &Keys{
SigningKey: &jose.JSONWebKey{
Key: signKey,
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
},
SigningKeyPub: &jose.JSONWebKey{
Key: signKey.Public(),
KeyID: keyID,
Algorithm: jwt.SigningMethodRS256.Alg(),
Use: "sig",
},
},
}
if !reflect.DeepEqual(got, want) {
@@ -116,8 +124,10 @@ func TestNewIssuerGenerateSignKey(t *testing.T) {
iss := got.(*issuer)
assert.NotNil(t, iss.signKey)
assert.NotNil(t, iss.signKey.Key)
assert.NotNil(t, iss.signKey.KeyID)
assert.NotNil(t, iss.signKey.SigningKey)
assert.NotNil(t, iss.signKey.SigningKeyPub)
assert.NotNil(t, iss.signKey.SigningKey.KeyID)
assert.NotNil(t, iss.signKey.SigningKeyPub.KeyID)
}
func Test_issuer_IssueTo(t *testing.T) {

View File

@@ -18,6 +18,8 @@ package oauth
import (
"fmt"
"gopkg.in/square/go-jose.v2"
"kubesphere.io/kubesphere/pkg/utils/sliceutil"
"net/http"
"net/url"
"strings"
@@ -82,6 +84,40 @@ func (request *TokenReview) Validate() error {
return nil
}
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
type discovery struct {
// URL using the https scheme with no query or fragment component that the OP
// asserts as its Issuer Identifier.
Issuer string `json:"issuer"`
// URL of the OP's OAuth 2.0 Authorization Endpoint.
Auth string `json:"authorization_endpoint"`
// URL of the OP's OAuth 2.0 Token Endpoint.
Token string `json:"token_endpoint"`
// URL of the OP's UserInfo Endpoint
UserInfo string `json:"userinfo_endpoint"`
// URL of the OP's JSON Web Key Set [JWK] document.
Keys string `json:"jwks_uri"`
// JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports.
GrantTypes []string `json:"grant_types_supported"`
// JSON array containing a list of the OAuth 2.0 response_type values that this OP supports.
ResponseTypes []string `json:"response_types_supported"`
// JSON array containing a list of the Subject Identifier types that this OP supports.
Subjects []string `json:"subject_types_supported"`
// JSON array containing a list of the JWS signing algorithms (alg values) supported by
// the OP for the ID Token to encode the Claims in a JWT [JWT].
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
// JSON array containing a list of Proof Key for Code
// Exchange (PKCE) [RFC7636] code challenge methods supported by this authorization server.
CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
// JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports.
Scopes []string `json:"scopes_supported"`
// JSON array containing a list of Client Authentication methods supported by this Token Endpoint.
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
// JSON array containing a list of the Claim Names of the Claims that the OpenID Provider
// MAY be able to supply values for.
Claims []string `json:"claims_supported"`
}
type handler struct {
im im.IdentityManagementInterface
options *authentication.Options
@@ -139,6 +175,39 @@ func (h *handler) tokenReview(req *restful.Request, resp *restful.Response) {
resp.WriteEntity(success)
}
func (h *handler) discovery(req *restful.Request, response *restful.Response) {
result := discovery{
Issuer: h.options.OAuthOptions.Issuer,
Auth: h.options.OAuthOptions.Issuer + "/authorize",
Token: h.options.OAuthOptions.Issuer + "/token",
Keys: h.options.OAuthOptions.Issuer + "/keys",
UserInfo: h.options.OAuthOptions.Issuer + "/userinfo",
Subjects: []string{"public"},
GrantTypes: []string{"authorization_code", "refresh_token"},
IDTokenAlgs: []string{string(jose.RS256)},
CodeChallengeAlgs: []string{"S256", "plain"},
Scopes: []string{"openid", "email", "profile", "offline_access"},
// TODO(hongming) support client_secret_jwt
AuthMethods: []string{"client_secret_basic", "client_secret_post"},
Claims: []string{
"iss", "sub", "aud", "iat", "exp", "email", "locale", "preferred_username",
},
ResponseTypes: []string{
"code",
"token",
},
}
response.WriteEntity(result)
}
func (h *handler) keys(req *restful.Request, response *restful.Response) {
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{*h.tokenOperator.Keys().SigningKeyPub},
}
response.WriteEntity(jwks)
}
// The Authorization Endpoint performs Authentication of the End-User.
func (h *handler) authorize(req *restful.Request, response *restful.Response) {
var scope, responseType, clientID, redirectURI, state, nonce string
@@ -207,7 +276,6 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) {
}
redirectURL.RawQuery = values.Encode()
http.Redirect(response.ResponseWriter, req.Request, redirectURL.String(), http.StatusFound)
return
}
// Other scope values MAY be present.
@@ -233,12 +301,14 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) {
},
TokenType: token.AuthorizationCode,
Nonce: nonce,
Scopes: scopes,
},
// A maximum authorization code lifetime of 10 minutes is
ExpiresIn: 10 * time.Minute,
})
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
values := redirectURL.Query()
values.Add("code", code)
@@ -255,6 +325,7 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) {
result, err := h.issueTokenTo(authenticated)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
values := make(url.Values, 0)
@@ -277,6 +348,7 @@ func (h *handler) oauthCallback(req *restful.Request, response *restful.Response
result, err := h.issueTokenTo(authenticated)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
requestInfo, _ := request.RequestInfoFrom(req.Request.Context())
@@ -382,6 +454,7 @@ func (h *handler) passwordGrant(username string, password string, req *restful.R
result, err := h.issueTokenTo(authenticated)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
requestInfo, _ := request.RequestInfoFrom(req.Request.Context())
@@ -392,9 +465,9 @@ func (h *handler) passwordGrant(username string, password string, req *restful.R
response.WriteEntity(result)
}
func (h *handler) issueTokenTo(authenticated user.Info) (*oauth.Token, error) {
func (h *handler) issueTokenTo(user user.Info) (*oauth.Token, error) {
accessToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{
User: authenticated,
User: user,
Claims: token.Claims{TokenType: token.AccessToken},
ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge,
})
@@ -402,7 +475,7 @@ func (h *handler) issueTokenTo(authenticated user.Info) (*oauth.Token, error) {
return nil, err
}
refreshToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{
User: authenticated,
User: user,
Claims: token.Claims{TokenType: token.RefreshToken},
ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout,
})
@@ -469,6 +542,7 @@ func (h *handler) refreshTokenGrant(req *restful.Request, response *restful.Resp
result, err := h.issueTokenTo(authenticated)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
response.WriteEntity(result)
@@ -486,26 +560,42 @@ func (h *handler) codeGrant(req *restful.Request, response *restful.Response) {
response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err))
return
}
if authorizeContext.TokenType != token.AuthorizationCode {
err = fmt.Errorf("ivalid token type %v want %v", authorizeContext.TokenType, token.AuthorizationCode)
response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err))
return
}
// The client MUST NOT use the authorization code more than once.
err = h.tokenOperator.Revoke(code)
if err != nil {
klog.Warningf("grant: failed to revoke authorization code: %v", err)
}
defer func() {
// The client MUST NOT use the authorization code more than once.
err = h.tokenOperator.Revoke(code)
if err != nil {
klog.Warningf("grant: failed to revoke authorization code: %v", err)
}
}()
authenticated := authorizeContext.User
result, err := h.issueTokenTo(authenticated)
result, err := h.issueTokenTo(authorizeContext.User)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
idToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{
User: authenticated,
// If no openid scope value is present, the request may still be a valid OAuth 2.0 request,
// but is not an OpenID Connect request.
if !sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeOpenID) {
response.WriteEntity(result)
return
}
authenticated, err := h.im.DescribeUser(authorizeContext.User.GetName())
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
idTokenRequest := &token.IssueRequest{
User: authorizeContext.User,
Claims: token.Claims{
StandardClaims: jwt.StandardClaims{
Audience: authorizeContext.Audience,
@@ -514,8 +604,25 @@ func (h *handler) codeGrant(req *restful.Request, response *restful.Response) {
TokenType: token.IDToken,
},
ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout,
})
}
if sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeProfile) {
idTokenRequest.PreferredUsername = authenticated.Name
idTokenRequest.Locale = authenticated.Spec.Lang
}
if sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeEmail) {
idTokenRequest.Email = authenticated.Spec.Email
}
idToken, err := h.tokenOperator.IssueTo(idTokenRequest)
if err != nil {
response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err))
return
}
result.IDToken = idToken
response.WriteEntity(result)
}

View File

@@ -53,6 +53,11 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface,
handler := newHandler(im, tokenOperator, passwordAuthenticator, oauth2Authenticator, loginRecorder, options)
ws.Route(ws.GET("/.well-known/openid-configuration").To(handler.discovery).
Doc("The OpenID Provider's configuration information can be retrieved."))
ws.Route(ws.GET("/keys").To(handler.keys).
Doc("OP's JSON Web Key Set [JWK] document."))
// Implement webhook authentication interface
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication
ws.Route(ws.POST("/authenticate").

View File

@@ -41,6 +41,8 @@ type TokenManagementInterface interface {
Revoke(token string) error
// RevokeAllUserTokens revoke all user tokens
RevokeAllUserTokens(username string) error
// Keys hold encryption and signing keys.
Keys() *token.Keys
}
type tokenOperator struct {
@@ -117,6 +119,10 @@ func (t *tokenOperator) RevokeAllUserTokens(username string) error {
return nil
}
func (t *tokenOperator) Keys() *token.Keys {
return t.issuer.Keys()
}
// tokenCacheValidate verify that the token is in the cache
func (t *tokenOperator) tokenCacheValidate(username, token string) error {
key := fmt.Sprintf("kubesphere:user:%s:token:%s", username, token)