318 lines
8.4 KiB
Go
318 lines
8.4 KiB
Go
/*
|
|
* Copyright 2024 the KubeSphere Authors.
|
|
* Please refer to the LICENSE file in the root directory of the project.
|
|
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
|
|
*/
|
|
|
|
package token
|
|
|
|
import (
|
|
cryptorand "crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/klog/v2"
|
|
|
|
"kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth"
|
|
)
|
|
|
|
const (
|
|
AccessToken Type = "access_token"
|
|
RefreshToken Type = "refresh_token"
|
|
StaticToken Type = "static_token"
|
|
AuthorizationCode Type = "code"
|
|
IDToken Type = "id_token"
|
|
headerKeyID string = "kid"
|
|
headerAlgorithm string = "alg"
|
|
)
|
|
|
|
type Type string
|
|
|
|
type IssueRequest struct {
|
|
User user.Info
|
|
ExpiresIn time.Duration
|
|
Claims
|
|
}
|
|
|
|
type VerifiedResponse struct {
|
|
User user.Info
|
|
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
|
|
IssueTo(request *IssueRequest) (string, error)
|
|
|
|
// 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 {
|
|
jwt.RegisteredClaims
|
|
// Private Claim Names
|
|
// TokenType defined the type of the token
|
|
TokenType Type `json:"token_type,omitempty"`
|
|
// Username user identity, deprecated field
|
|
Username string `json:"username,omitempty"`
|
|
// Extra contains the additional information
|
|
Extra map[string][]string `json:"extra,omitempty"`
|
|
|
|
// Used for issuing authorization code
|
|
// Scopes can be used to request that specific sets of information be made available as Claim Values.
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
|
|
// The following is well-known ID Token fields
|
|
|
|
// End-User's full url in displayable form including all url parts,
|
|
// possibly including titles and suffixes, ordered according to the End-User's locale and preferences.
|
|
Name string `json:"url,omitempty"`
|
|
// 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"`
|
|
// 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 url by which the End-User wishes to be referred to at the RP,
|
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
|
}
|
|
|
|
type issuer struct {
|
|
// Issuer Identifier
|
|
url string
|
|
// signing access_token and refresh_token
|
|
secret []byte
|
|
// signing id_token
|
|
signKey *Keys
|
|
// Token verification maximum time difference
|
|
maximumClockSkew time.Duration
|
|
}
|
|
|
|
func (s *issuer) IssueTo(request *IssueRequest) (string, error) {
|
|
issueAt := time.Now()
|
|
claims := Claims{
|
|
Username: request.User.GetName(),
|
|
Extra: request.User.GetExtra(),
|
|
TokenType: request.TokenType,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(issueAt),
|
|
Subject: request.User.GetName(),
|
|
Issuer: s.url,
|
|
},
|
|
}
|
|
|
|
if len(request.Audience) > 0 {
|
|
claims.Audience = request.Audience
|
|
}
|
|
if request.Name != "" {
|
|
claims.Name = request.Name
|
|
}
|
|
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 = jwt.NewNumericDate(issueAt.Add(request.ExpiresIn))
|
|
}
|
|
|
|
var token string
|
|
var err error
|
|
if request.TokenType == IDToken {
|
|
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
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)
|
|
}
|
|
if err != nil {
|
|
klog.Warningf("jwt: failed to issue token: %v", err)
|
|
return "", err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (s *issuer) Verify(token string) (*VerifiedResponse, error) {
|
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg(), jwt.SigningMethodRS256.Alg()}),
|
|
jwt.WithoutClaimsValidation())
|
|
|
|
var claims Claims
|
|
_, err := parser.ParseWithClaims(token, &claims, s.keyFunc)
|
|
if err != nil {
|
|
klog.Warningf("jwt: failed to parse token: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
if !claims.VerifyExpiresAt(now, false) {
|
|
delta := now.Sub(claims.ExpiresAt.Time)
|
|
err = fmt.Errorf("jwt: token is expired by %v", delta)
|
|
klog.V(4).Info(err)
|
|
return nil, err
|
|
}
|
|
|
|
// allowing a clock skew when checking the time-based values.
|
|
skewedTime := now.Add(s.maximumClockSkew)
|
|
if !claims.VerifyIssuedAt(skewedTime, false) {
|
|
err = fmt.Errorf("jwt: token used before issued, iat:%v, now:%v", claims.IssuedAt, now)
|
|
klog.Warning(err)
|
|
return nil, err
|
|
}
|
|
|
|
verified := &VerifiedResponse{
|
|
User: &user.DefaultInfo{
|
|
Name: claims.Username,
|
|
Extra: claims.Extra,
|
|
},
|
|
Claims: claims,
|
|
}
|
|
|
|
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.SigningKey.Key, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpect signature algorithm %v", token.Header[headerAlgorithm])
|
|
}
|
|
}
|
|
|
|
func loadPrivateKey(data []byte) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode(data)
|
|
if block == nil {
|
|
return nil, errors.New("private key not in pem format")
|
|
}
|
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to key file: %v", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func generatePrivateKeyData() ([]byte, error) {
|
|
privateKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate private key: %v", err)
|
|
}
|
|
data := x509.MarshalPKCS1PrivateKey(privateKey)
|
|
pemData := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: data,
|
|
},
|
|
)
|
|
return pemData, nil
|
|
}
|
|
|
|
func loadSignKey(config *oauth.IssuerOptions) (*rsa.PrivateKey, string, error) {
|
|
var signKey *rsa.PrivateKey
|
|
var signKeyData []byte
|
|
var err error
|
|
|
|
if config.SignKey != "" {
|
|
signKeyData, err = os.ReadFile(config.SignKey)
|
|
if err != nil {
|
|
klog.Errorf("issuer: failed to read private key file %s: %v", config.SignKey, err)
|
|
return nil, "", err
|
|
}
|
|
} else if config.SignKeyData != "" {
|
|
signKeyData, err = base64.StdEncoding.DecodeString(config.SignKeyData)
|
|
if err != nil {
|
|
klog.Errorf("issuer: failed to decode sign key data: %s", err)
|
|
return nil, "", err
|
|
}
|
|
}
|
|
|
|
// automatically generate private key
|
|
if len(signKeyData) == 0 {
|
|
signKeyData, err = generatePrivateKeyData()
|
|
if err != nil {
|
|
klog.Errorf("issuer: failed to generate private key: %v", err)
|
|
return nil, "", err
|
|
}
|
|
}
|
|
|
|
if len(signKeyData) > 0 {
|
|
signKey, err = loadPrivateKey(signKeyData)
|
|
if err != nil {
|
|
klog.Errorf("issuer: failed to load private key from data: %v", err)
|
|
}
|
|
}
|
|
|
|
keyID := fmt.Sprint(fnv32a(signKeyData))
|
|
return signKey, keyID, nil
|
|
}
|
|
|
|
func NewIssuer(config *oauth.IssuerOptions) (Issuer, error) {
|
|
// TODO(hongming) automatically rotates keys
|
|
signKey, keyID, err := loadSignKey(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &issuer{
|
|
url: config.URL,
|
|
secret: []byte(config.JWTSecret),
|
|
maximumClockSkew: config.MaximumClockSkew,
|
|
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
|
|
}
|
|
|
|
// fnv32a hashes using fnv32a algorithm
|
|
func fnv32a(data []byte) uint32 {
|
|
algorithm := fnv.New32a()
|
|
algorithm.Write(data)
|
|
return algorithm.Sum32()
|
|
}
|