Files

365 lines
12 KiB
Go

package identityprovider
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
type requestState struct {
Redirect string `json:"redirect"`
ClientID string `json:"client_id"`
State string `json:"state"`
Nonce string `json:"nonce"`
Challenge string `json:"challenge"`
}
type codeData struct {
requestState
Subject string `json:"subject"`
Name string `json:"name"`
}
type accessData struct {
Subject string `json:"sub"`
Name string `json:"name"`
}
func noStore(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Header("Pragma", "no-cache") }
func oidcError(c *gin.Context, status int) {
noStore(c)
c.JSON(status, gin.H{"error": "invalid_request"})
}
func (p *Provider) Discovery(c *gin.Context) {
_, base, err := enabled()
if err != nil {
oidcError(c, http.StatusNotFound)
return
}
noStore(c)
c.JSON(200, gin.H{
"issuer": base, "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token",
"userinfo_endpoint": base + "/userinfo", "jwks_uri": base + "/jwks",
"response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"},
"grant_types_supported": []string{"authorization_code"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
})
}
func (p *Provider) JWKS(c *gin.Context) {
if _, _, err := enabled(); err != nil {
oidcError(c, 404)
return
}
key, kid, err := signingKey()
if err != nil {
oidcError(c, 503)
return
}
noStore(c)
c.JSON(200, gin.H{"keys": []gin.H{{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid,
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}})
}
func (p *Provider) Authorize(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
redirect := c.Query("redirect_uri")
publicBase := strings.TrimSuffix(base, "/id")
if !validateRedirect(redirect, publicBase) || c.Query("client_id") != cfg.ClientID || c.Query("response_type") != "code" || !strings.Contains(" "+c.Query("scope")+" ", " openid ") {
oidcError(c, 400)
return
}
challenge, method := c.Query("code_challenge"), c.Query("code_challenge_method")
if (challenge != "" && (method != "S256" || len(challenge) != 43)) || (challenge == "" && method != "") || len(c.Query("state")) > 1024 || len(c.Query("nonce")) > 1024 {
oidcError(c, 400)
return
}
state, err := randomToken()
if err != nil {
oidcError(c, 503)
return
}
pending := requestState{Redirect: redirect, ClientID: cfg.ClientID, State: c.Query("state"), Nonce: c.Query("nonce"), Challenge: challenge}
if err := saveGrant("state", state, pending, 5*time.Minute); err != nil {
oidcError(c, 503)
return
}
upstream := feishuAuthorizeURL(cfg.AppID, base+"/feishu/callback", state)
noStore(c)
c.Redirect(http.StatusFound, upstream)
}
func feishuAuthorizeURL(appID, callback, state string) string {
return feishuAuthorize + "?" + url.Values{
"client_id": {appID}, "redirect_uri": {callback}, "response_type": {"code"},
"scope": {"contact:user.base:readonly"}, "state": {state},
}.Encode()
}
var upstreamClient = &http.Client{Timeout: 8 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
func postFeishuToken(ctx context.Context, appID, appSecret, code, callback string) (string, error) {
payload, _ := json.Marshal(gin.H{"grant_type": "authorization_code", "client_id": appID, "client_secret": appSecret, "code": code, "redirect_uri": callback})
req, err := http.NewRequestWithContext(ctx, "POST", feishuToken, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
response, err := upstreamClient.Do(req)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return "", errors.New("upstream token rejected")
}
var data struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&data); err != nil || data.Code == nil || *data.Code != 0 {
return "", errors.New("upstream token invalid")
}
if data.AccessToken != "" {
return data.AccessToken, nil
}
if data.Data.AccessToken != "" {
return data.Data.AccessToken, nil
}
return "", errors.New("upstream access token missing")
}
func fetchFeishuUser(ctx context.Context, token string) (accessData, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", feishuUserInfo, nil)
if err != nil {
return accessData{}, "", err
}
req.Header.Set("Authorization", "Bearer "+token)
response, err := upstreamClient.Do(req)
if err != nil {
return accessData{}, "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return accessData{}, "", errors.New("upstream user rejected")
}
var result struct {
Code *int `json:"code"`
Data struct {
OpenID string `json:"open_id"`
TenantKey string `json:"tenant_key"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil || result.Code == nil || *result.Code != 0 || result.Data.OpenID == "" || result.Data.TenantKey == "" {
return accessData{}, "", errors.New("upstream user missing stable identity")
}
return accessData{Subject: result.Data.TenantKey + ":" + result.Data.OpenID, Name: result.Data.Name}, result.Data.TenantKey, nil
}
func (p *Provider) FeishuCallback(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
var state requestState
if err := consumeGrant("state", c.Query("state"), &state); err != nil || c.Query("error") != "" || c.Query("code") == "" || len(c.Query("code")) > 2048 {
oidcError(c, 400)
return
}
secret, err := store.DecryptSecret(cfg.AppSecret)
if err != nil {
oidcError(c, 503)
return
}
token, err := postFeishuToken(c.Request.Context(), cfg.AppID, secret, c.Query("code"), base+"/feishu/callback")
if err != nil {
oidcError(c, 502)
return
}
user, tenant, err := fetchFeishuUser(c.Request.Context(), token)
if err != nil {
oidcError(c, 502)
return
}
if tenant != cfg.TenantKey {
oidcError(c, 403)
return
}
allowed, err := admitted(state.Redirect, base, cfg, user.Subject)
if err != nil {
oidcError(c, 503)
return
}
if !allowed {
oidcError(c, 403)
return
}
directoryContext, cancelDirectory := context.WithTimeout(c.Request.Context(), 4*time.Second)
defer cancelDirectory()
openID := strings.TrimPrefix(user.Subject, tenant+":")
// The browser grant can have user-level contact permission even when the
// application's tenant token lacks basic_batch permission.
if name, err := fetchBasicName(directoryContext, token, openID); err == nil {
user.Name = name
} else if name, err := fetchDirectoryName(directoryContext, cfg.AppID, secret, openID); err == nil {
user.Name = name
}
if err := saveProfile(base, cfg, user); err != nil {
oidcError(c, 503)
return
}
code, err := randomToken()
if err != nil {
oidcError(c, 503)
return
}
grant := codeData{requestState: state, Subject: user.Subject, Name: user.Name}
if err := saveGrant("code", code, grant, 2*time.Minute); err != nil {
oidcError(c, 503)
return
}
// A successful user-info lookup confirms this application's browser scope.
// This timestamp contains no identity; the verified subject and display name
// are stored separately in IdentityProfile. Reset the evidence when credentials change.
_ = store.DB.Model(&model.IdentityProvider{}).Where("id = ? AND app_id = ? AND tenant_key = ?", cfg.ID, cfg.AppID, cfg.TenantKey).Update("user_scope_verified_at", time.Now()).Error
u, _ := url.Parse(state.Redirect)
q := u.Query()
q.Set("code", code)
if state.State != "" {
q.Set("state", state.State)
}
u.RawQuery = q.Encode()
noStore(c)
c.Redirect(http.StatusFound, u.String())
}
func bearer(c *gin.Context, cfg model.IdentityProvider) bool {
id, secret, ok := c.Request.BasicAuth()
if !ok {
id, secret = c.PostForm("client_id"), c.PostForm("client_secret")
}
stored, err := store.DecryptSecret(cfg.ClientSecret)
return err == nil && subtle.ConstantTimeCompare([]byte(id), []byte(cfg.ClientID)) == 1 && subtle.ConstantTimeCompare([]byte(secret), []byte(stored)) == 1
}
func signIDToken(key *rsa.PrivateKey, kid, base string, cfg model.IdentityProvider, grant codeData) (string, error) {
now := time.Now().Unix()
header, _ := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": kid})
claims := gin.H{"iss": base, "aud": cfg.ClientID, "sub": grant.Subject, "iat": now, "exp": now + 600, "auth_time": now}
if grant.Name != "" {
claims["name"] = grant.Name
}
if grant.Nonce != "" {
claims["nonce"] = grant.Nonce
}
body, _ := json.Marshal(claims)
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body)
hash := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
if err != nil {
return "", err
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func (p *Provider) Token(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
noStore(c)
if !bearer(c, cfg) || c.PostForm("grant_type") != "authorization_code" {
c.JSON(401, gin.H{"error": "invalid_client"})
return
}
var grant codeData
if err := consumeGrant("code", c.PostForm("code"), &grant); err != nil {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
if grant.ClientID != cfg.ClientID || grant.Redirect != c.PostForm("redirect_uri") {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
if grant.Challenge != "" {
verifier := c.PostForm("code_verifier")
if len(verifier) < 43 || len(verifier) > 128 {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
hash := sha256.Sum256([]byte(verifier))
if subtle.ConstantTimeCompare([]byte(grant.Challenge), []byte(base64.RawURLEncoding.EncodeToString(hash[:]))) != 1 {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
}
key, kid, err := signingKey()
if err != nil {
oidcError(c, 503)
return
}
idToken, err := signIDToken(key, kid, base, cfg, grant)
if err != nil {
oidcError(c, 503)
return
}
access, err := randomToken()
if err != nil || saveGrant("access", access, accessData{Subject: grant.Subject, Name: grant.Name}, 10*time.Minute) != nil {
oidcError(c, 503)
return
}
c.JSON(200, gin.H{"access_token": access, "id_token": idToken, "token_type": "Bearer", "expires_in": 600, "scope": "openid"})
}
func (p *Provider) UserInfo(c *gin.Context) {
if _, _, err := enabled(); err != nil {
oidcError(c, 503)
return
}
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == c.GetHeader("Authorization") || len(token) < 32 {
oidcError(c, 401)
return
}
var grant model.IdentityGrant
if err := store.DB.Where("kind = ? AND digest = ? AND expires_at > ?", "access", digest(token), time.Now()).First(&grant).Error; err != nil {
oidcError(c, 401)
return
}
var user accessData
if json.Unmarshal([]byte(grant.Payload), &user) != nil {
oidcError(c, 401)
return
}
noStore(c)
c.JSON(200, gin.H{"sub": user.Subject, "name": user.Name})
}