diff --git a/authserver/.env.example b/authserver/.env.example
index 827f866..9092794 100644
--- a/authserver/.env.example
+++ b/authserver/.env.example
@@ -1,6 +1,12 @@
APP_ENV=dev
HTTP_ADDR=:8080
PUBLIC_BASE_URL=http://localhost:8080
+OIDC_ISSUER=http://localhost:8080/auth
+OIDC_AUTHORIZATION_ENDPOINT=http://localhost:8080/auth/oauth/authorize
+OIDC_TOKEN_ENDPOINT=http://localhost:8080/auth/oauth/token
+OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo
+OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks
+CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local
AUTO_MIGRATE=true
diff --git a/authserver/README.md b/authserver/README.md
index 80d6010..37c0c92 100644
--- a/authserver/README.md
+++ b/authserver/README.md
@@ -161,9 +161,11 @@ bash scripts/restart-authserver.sh nginx
| `GET` | `/api/v1/saml/metadata` | SAML SP metadata |
| `GET` | `/api/v1/login/internal-sso` | 发起 SAML SSO 登录 |
| `POST` | `/api/v1/saml/acs` | SAML ACS 回调,当前仅调试打印 |
+| `GET` | `/auth/.well-known/openid-configuration` | OIDC Discovery 配置 |
| `GET` | `/auth/oauth/authorize` | OAuth2 Authorization Code 授权入口,供 Wayne 使用 |
| `POST` | `/auth/oauth/token` | OAuth2 code 换 access token |
| `GET` | `/auth/oauth/userinfo` | OAuth2 bearer token 查询当前用户 |
+| `GET` | `/auth/oauth/jwks` | OIDC JWKS 公钥 |
## SAML Metadata
@@ -232,6 +234,7 @@ AuthServer 端配置:
OAUTH_WAYNE_CLIENT_ID=wayne
OAUTH_WAYNE_CLIENT_SECRET=change-this-wayne-client-secret
OAUTH_WAYNE_REDIRECT_URI=http://127.0.0.1:8080/login/oauth2/oauth2
+WAYEN_OAUTH_REF=/portal/namespace/1/app
OAUTH_CODE_TTL_SECONDS=120
```
@@ -243,6 +246,19 @@ POST /auth/oauth/token
GET /auth/oauth/userinfo
```
+OIDC Discovery 里的 endpoint 默认由 `OIDC_ISSUER` 拼接,也可以按 endpoint 单独覆盖。浏览器需要访问 `OIDC_AUTHORIZATION_ENDPOINT`,后端系统通常访问 `OIDC_TOKEN_ENDPOINT`、`OIDC_USERINFO_ENDPOINT` 和 `OIDC_JWKS_URI`。
+
+```env
+OIDC_ISSUER=http://auth.example.com/auth
+OIDC_AUTHORIZATION_ENDPOINT=http://auth.example.com/auth/oauth/authorize
+OIDC_TOKEN_ENDPOINT=http://auth-internal.example.com/auth/oauth/token
+OIDC_USERINFO_ENDPOINT=http://auth-internal.example.com/auth/oauth/userinfo
+OIDC_JWKS_URI=http://auth-internal.example.com/auth/oauth/jwks
+CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
+```
+
+`CLOUDDM_TARGET_URL` 用于 AuthServer 后端请求 CloudDM `/requestJumpUrl`。在 k8s 内建议指向 AuthServer nginx 的内部代理路径,由 nginx 转发到 CloudDM Service,并把 `Host` 固定成 CloudDM 公网入口,确保 CloudDM 生成浏览器可访问的 callback。
+
Wayne `app.conf` 示例:
```ini
@@ -265,6 +281,7 @@ Wayne 会把回调地址拼成:
```
因此 `OAUTH_WAYNE_REDIRECT_URI` 必须和 Wayne 实际回调地址完全一致。浏览器访问 Wayne OAuth 登录入口后,如果 AuthServer 还没有登录态,会先跳内部 SAML;SAML 成功后再回到 OAuth authorize,签发 code 给 Wayne。
+`WAYEN_OAUTH_REF` 是 AuthServer 发起 Wayne 登录时写入 Wayne `next` 参数的登录完成页,默认 `/portal/namespace/1/app`,对应 Wayne `DemoNamespaceId = 1` 的默认 namespace。不要配置成 `oauth` 或 `/oauth`,否则 Wayne 回调会把它当成前端路由跳到 `/oauth`。
管理员可查看当前 SAML metadata 配置:
diff --git a/authserver/internal/auth/jwt.go b/authserver/internal/auth/jwt.go
index ef9e786..9a6ea4f 100644
--- a/authserver/internal/auth/jwt.go
+++ b/authserver/internal/auth/jwt.go
@@ -1,6 +1,7 @@
package auth
import (
+ "crypto/rsa"
"errors"
"time"
@@ -19,6 +20,18 @@ type OAuthCodeClaims struct {
UserID uint64 `json:"uid"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
+ Scope string `json:"scope"`
+ Nonce string `json:"nonce,omitempty"`
+ jwt.RegisteredClaims
+}
+
+type IDTokenClaims struct {
+ UserID uint64 `json:"uid"`
+ Username string `json:"preferred_username"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ Nonce string `json:"nonce,omitempty"`
jwt.RegisteredClaims
}
@@ -45,7 +58,7 @@ func Sign(secret, issuer string, ttl time.Duration, userID uint64, username, ema
return signed, tokenID, expiresAt, err
}
-func SignOAuthCode(secret, issuer string, ttl time.Duration, userID uint64, clientID, redirectURI string) (string, string, time.Time, error) {
+func SignOAuthCode(secret, issuer string, ttl time.Duration, userID uint64, clientID, redirectURI, scope, nonce string) (string, string, time.Time, error) {
now := time.Now()
expiresAt := now.Add(ttl)
codeID := randomID()
@@ -53,6 +66,8 @@ func SignOAuthCode(secret, issuer string, ttl time.Duration, userID uint64, clie
UserID: userID,
ClientID: clientID,
RedirectURI: redirectURI,
+ Scope: scope,
+ Nonce: nonce,
RegisteredClaims: jwt.RegisteredClaims{
ID: codeID,
Issuer: issuer,
@@ -67,6 +82,36 @@ func SignOAuthCode(secret, issuer string, ttl time.Duration, userID uint64, clie
return signed, codeID, expiresAt, err
}
+func SignIDToken(privateKey *rsa.PrivateKey, keyID, issuer, clientID string, ttl time.Duration, userID uint64, username, email, name, nonce string) (string, time.Time, error) {
+ now := time.Now()
+ expiresAt := now.Add(ttl)
+ subject := email
+ if subject == "" {
+ subject = username
+ }
+ claims := IDTokenClaims{
+ UserID: userID,
+ Username: username,
+ Email: email,
+ EmailVerified: email != "",
+ Name: name,
+ Nonce: nonce,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: issuer,
+ Subject: subject,
+ Audience: []string{clientID},
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(expiresAt),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ if keyID != "" {
+ token.Header["kid"] = keyID
+ }
+ signed, err := token.SignedString(privateKey)
+ return signed, expiresAt, err
+}
+
func Parse(secret string, tokenValue string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
diff --git a/authserver/internal/auth/oidc_key.go b/authserver/internal/auth/oidc_key.go
new file mode 100644
index 0000000..fde8bfc
--- /dev/null
+++ b/authserver/internal/auth/oidc_key.go
@@ -0,0 +1,64 @@
+package auth
+
+import (
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "errors"
+ "math/big"
+ "os"
+)
+
+type JWK struct {
+ KeyType string `json:"kty"`
+ Use string `json:"use"`
+ KeyID string `json:"kid"`
+ Algorithm string `json:"alg"`
+ Modulus string `json:"n"`
+ Exponent string `json:"e"`
+}
+
+func LoadRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
+ if path == "" {
+ return nil, errors.New("rsa private key file is required")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ block, _ := pem.Decode(data)
+ if block == nil {
+ return nil, errors.New("rsa private key file has no PEM block")
+ }
+ if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+ return key, nil
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ key, ok := parsed.(*rsa.PrivateKey)
+ if !ok {
+ return nil, errors.New("private key is not an RSA private key")
+ }
+ return key, nil
+}
+
+func PublicJWKFromKey(key *rsa.PrivateKey) JWK {
+ publicKey := key.PublicKey
+ return JWK{
+ KeyType: "RSA",
+ Use: "sig",
+ KeyID: RSAKeyID(&publicKey),
+ Algorithm: "RS256",
+ Modulus: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()),
+ Exponent: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()),
+ }
+}
+
+func RSAKeyID(publicKey *rsa.PublicKey) string {
+ hash := sha256.Sum256(publicKey.N.Bytes())
+ return base64.RawURLEncoding.EncodeToString(hash[:8])
+}
diff --git a/authserver/internal/config/config.go b/authserver/internal/config/config.go
index d5a63fa..9e58da7 100644
--- a/authserver/internal/config/config.go
+++ b/authserver/internal/config/config.go
@@ -7,6 +7,12 @@ import (
"time"
)
+type OAuthClient struct {
+ ID string
+ Secret string
+ RedirectURIs []string
+}
+
type Config struct {
AppEnv string
HTTPAddr string
@@ -27,10 +33,20 @@ type Config struct {
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
+ WayenOAuthRef string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
+ OIDCIssuer string
+ OIDCAuthorizeURL string
+ OIDCTokenURL string
+ OIDCUserInfoURL string
+ OIDCJWKSURL string
+ CloudDMClientID string
+ CloudDMClientSecret string
+ CloudDMRedirectURI string
+ CloudDMTargetURL string
}
func Load() Config {
@@ -39,6 +55,8 @@ func Load() Config {
publicBaseURL := env("PUBLIC_BASE_URL", defaultPublicBaseURL(httpAddr))
samlEntityID := env("SAML_ENTITY_ID", strings.TrimRight(publicBaseURL, "/")+"/auth/api/v1/saml/metadata")
samlACSURL := env("SAML_ACS_URL", strings.TrimRight(publicBaseURL, "/")+"/auth/api/v1/saml/acs")
+ oidcIssuer := env("OIDC_ISSUER", strings.TrimRight(publicBaseURL, "/")+"/auth")
+ oidcIssuer = strings.TrimRight(oidcIssuer, "/")
return Config{
AppEnv: env("APP_ENV", "dev"),
@@ -60,10 +78,20 @@ func Load() Config {
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
+ WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
+ OIDCIssuer: oidcIssuer,
+ OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
+ OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
+ OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
+ OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
+ CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
+ CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
+ CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
+ CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
}
}
@@ -99,6 +127,42 @@ func (c Config) OAuthCodeTTL() time.Duration {
return time.Duration(c.OAuthCodeTTLSeconds) * time.Second
}
+func (c Config) OAuthClients() map[string]OAuthClient {
+ clients := make(map[string]OAuthClient)
+ addOAuthClient(clients, c.OAuthClientID, c.OAuthClientSecret, c.OAuthRedirectURI)
+ addOAuthClient(clients, c.CloudDMClientID, c.CloudDMClientSecret, c.CloudDMRedirectURI)
+ return clients
+}
+
+func addOAuthClient(clients map[string]OAuthClient, id, secret, redirectURIs string) {
+ id = strings.TrimSpace(id)
+ secret = strings.TrimSpace(secret)
+ if id == "" || secret == "" {
+ return
+ }
+ clients[id] = OAuthClient{
+ ID: id,
+ Secret: secret,
+ RedirectURIs: splitCSV(redirectURIs),
+ }
+}
+
+func splitCSV(value string) []string {
+ parts := strings.Split(value, ",")
+ items := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ items = append(items, part)
+ }
+ }
+ return items
+}
+
+func trimURL(value string) string {
+ return strings.TrimRight(strings.TrimSpace(value), "/")
+}
+
func env(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
diff --git a/authserver/internal/handler/clouddm.go b/authserver/internal/handler/clouddm.go
new file mode 100644
index 0000000..904e6b8
--- /dev/null
+++ b/authserver/internal/handler/clouddm.go
@@ -0,0 +1,111 @@
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "authserver/internal/config"
+ "authserver/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type CloudDMHandler struct {
+ cfg config.Config
+ audit *service.AuditService
+}
+
+func NewCloudDMHandler(cfg config.Config, audit *service.AuditService) *CloudDMHandler {
+ return &CloudDMHandler{cfg: cfg, audit: audit}
+}
+
+func (h *CloudDMHandler) Login(c *gin.Context) {
+ claims, ok := CurrentClaims(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
+ return
+ }
+
+ targetURL := strings.TrimSpace(h.cfg.CloudDMTargetURL)
+ if targetURL == "" {
+ h.writeAudit(c, claims.UserID, claims.Username, "deny", "clouddm target url is not configured")
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "clouddm target url is not configured"})
+ return
+ }
+
+ jumpURL, err := h.loginJumpURL(targetURL)
+ if err != nil {
+ h.writeAudit(c, claims.UserID, claims.Username, "deny", err.Error())
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ return
+ }
+
+ h.writeAudit(c, claims.UserID, claims.Username, "allow", "")
+ if strings.Contains(c.GetHeader("Accept"), "application/json") {
+ c.JSON(http.StatusOK, gin.H{"target_url": jumpURL})
+ return
+ }
+ c.Redirect(http.StatusFound, jumpURL)
+}
+
+func (h *CloudDMHandler) loginJumpURL(targetURL string) (string, error) {
+ requestURL := strings.TrimRight(targetURL, "/") + "/requestJumpUrl"
+ body, _ := json.Marshal(gin.H{"type": "OIDC"})
+ req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("clouddm requestJumpUrl failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+
+ var result struct {
+ Success bool `json:"success"`
+ Data string `json:"data"`
+ Msg string `json:"msg"`
+ MsgContent string `json:"msgContent"`
+ }
+ if err := json.Unmarshal(raw, &result); err != nil {
+ return "", err
+ }
+ if !result.Success || strings.TrimSpace(result.Data) == "" {
+ reason := strings.TrimSpace(result.MsgContent)
+ if reason == "" {
+ reason = strings.TrimSpace(result.Msg)
+ }
+ if reason == "" {
+ reason = "empty clouddm jump url"
+ }
+ return "", fmt.Errorf("clouddm requestJumpUrl failed: %s", reason)
+ }
+ return result.Data, nil
+}
+
+func (h *CloudDMHandler) writeAudit(c *gin.Context, userID uint64, username, decision, reason string) {
+ h.audit.Write(service.AuditEntry{
+ ActorUserID: userID,
+ ActorUsername: username,
+ ClientIP: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ Action: "clouddm.login",
+ ResourceType: "clouddm",
+ Decision: decision,
+ Reason: reason,
+ })
+}
diff --git a/authserver/internal/handler/oauth.go b/authserver/internal/handler/oauth.go
index db61b34..cffa577 100644
--- a/authserver/internal/handler/oauth.go
+++ b/authserver/internal/handler/oauth.go
@@ -40,12 +40,15 @@ func (h *OAuthHandler) Authorize(c *gin.Context) {
redirectURI := strings.TrimSpace(c.Query("redirect_uri"))
responseType := strings.TrimSpace(c.Query("response_type"))
state := c.Query("state")
+ scope := strings.TrimSpace(c.Query("scope"))
+ nonce := strings.TrimSpace(c.Query("nonce"))
if responseType != "code" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported response_type"})
return
}
- if !h.validClientRedirect(clientID, redirectURI) {
+ client, ok := h.client(clientID)
+ if !ok || !validClientRedirect(client, redirectURI) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid client_id or redirect_uri"})
return
}
@@ -57,7 +60,7 @@ func (h *OAuthHandler) Authorize(c *gin.Context) {
return
}
- code, codeID, expiresAt, err := auth.SignOAuthCode(h.cfg.JWTSecret, h.cfg.JWTIssuer, h.cfg.OAuthCodeTTL(), user.ID, clientID, redirectURI)
+ code, codeID, expiresAt, err := auth.SignOAuthCode(h.cfg.JWTSecret, h.cfg.JWTIssuer, h.cfg.OAuthCodeTTL(), user.ID, clientID, redirectURI, scope, nonce)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -119,7 +122,8 @@ func (h *OAuthHandler) Token(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported grant_type"})
return
}
- if clientID != h.cfg.OAuthClientID || clientSecret != h.cfg.OAuthClientSecret {
+ client, ok := h.client(clientID)
+ if !ok || clientSecret != client.Secret {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid client credentials"})
return
}
@@ -129,12 +133,13 @@ func (h *OAuthHandler) Token(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code"})
return
}
- if codeClaims.ClientID != clientID || codeClaims.RedirectURI != redirectURI || !h.validClientRedirect(clientID, redirectURI) {
+ if codeClaims.ClientID != clientID || codeClaims.RedirectURI != redirectURI || !validClientRedirect(client, redirectURI) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code"})
return
}
var token string
+ var idToken string
var expiresAt time.Time
var user model.User
now := time.Now()
@@ -152,12 +157,25 @@ func (h *OAuthHandler) Token(c *gin.Context) {
return service.ErrUserDisabled
}
+ display := strings.TrimSpace(user.DisplayName)
+ if display == "" {
+ display = user.Username
+ }
tokenID := ""
var err error
token, tokenID, expiresAt, err = auth.Sign(h.cfg.JWTSecret, h.cfg.JWTIssuer, h.cfg.JWTTTL(), user.ID, user.Username, user.Email, user.IsAdmin)
if err != nil {
return err
}
+ privateKey, err := auth.LoadRSAPrivateKey(h.cfg.SAMLSPKey)
+ if err != nil {
+ return err
+ }
+ keyID := auth.RSAKeyID(&privateKey.PublicKey)
+ idToken, _, err = auth.SignIDToken(privateKey, keyID, h.cfg.OIDCIssuer, clientID, h.cfg.JWTTTL(), user.ID, user.Username, user.Email, display, codeClaims.Nonce)
+ if err != nil {
+ return err
+ }
if err := tx.Model(&codeRecord).Updates(map[string]any{
"revoked": true,
"revoked_at": &now,
@@ -195,11 +213,41 @@ func (h *OAuthHandler) Token(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"access_token": token,
+ "id_token": idToken,
"token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()),
})
}
+func (h *OAuthHandler) Discovery(c *gin.Context) {
+ issuer := strings.TrimRight(h.cfg.OIDCIssuer, "/")
+ c.JSON(http.StatusOK, gin.H{
+ "issuer": issuer,
+ "authorization_endpoint": h.cfg.OIDCAuthorizeURL,
+ "token_endpoint": h.cfg.OIDCTokenURL,
+ "userinfo_endpoint": h.cfg.OIDCUserInfoURL,
+ "jwks_uri": h.cfg.OIDCJWKSURL,
+ "response_types_supported": []string{"code"},
+ "grant_types_supported": []string{"authorization_code"},
+ "subject_types_supported": []string{"public"},
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ "scopes_supported": []string{"openid", "profile", "email"},
+ "claims_supported": []string{"sub", "name", "preferred_username", "email", "email_verified"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
+ })
+}
+
+func (h *OAuthHandler) JWKS(c *gin.Context) {
+ privateKey, err := auth.LoadRSAPrivateKey(h.cfg.SAMLSPKey)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "keys": []auth.JWK{auth.PublicJWKFromKey(privateKey)},
+ })
+}
+
func (h *OAuthHandler) UserInfo(c *gin.Context) {
claims, err := bearerClaims(h.cfg, c.GetHeader("Authorization"))
if err != nil {
@@ -224,22 +272,35 @@ func (h *OAuthHandler) UserInfo(c *gin.Context) {
display = user.Username
}
c.JSON(http.StatusOK, gin.H{
- "name": user.Username,
- "email": user.Email,
- "display": display,
+ "sub": user.Email,
+ "name": user.Username,
+ "preferred_username": user.Username,
+ "email": user.Email,
+ "email_verified": user.Email != "",
+ "display": display,
})
}
func (h *OAuthHandler) oauthConfigured() bool {
- return h.cfg.OAuthClientID != "" && h.cfg.OAuthClientSecret != ""
+ return len(h.cfg.OAuthClients()) > 0
}
-func (h *OAuthHandler) validClientRedirect(clientID, redirectURI string) bool {
- if clientID == "" || clientID != h.cfg.OAuthClientID || redirectURI == "" {
+func (h *OAuthHandler) client(clientID string) (config.OAuthClient, bool) {
+ client, ok := h.cfg.OAuthClients()[strings.TrimSpace(clientID)]
+ return client, ok
+}
+
+func validClientRedirect(client config.OAuthClient, redirectURI string) bool {
+ if redirectURI == "" {
return false
}
- if h.cfg.OAuthRedirectURI != "" {
- return redirectURI == h.cfg.OAuthRedirectURI
+ if len(client.RedirectURIs) > 0 {
+ for _, allowed := range client.RedirectURIs {
+ if redirectURI == allowed {
+ return true
+ }
+ }
+ return false
}
parsed, err := url.Parse(redirectURI)
return err == nil && parsed.IsAbs() && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
diff --git a/authserver/internal/router/router.go b/authserver/internal/router/router.go
index 01556c4..3390113 100644
--- a/authserver/internal/router/router.go
+++ b/authserver/internal/router/router.go
@@ -29,13 +29,16 @@ func New(deps Dependencies) *gin.Engine {
healthHandler := handler.NewHealthHandler(deps.DB)
userHandler := handler.NewUserHandler()
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
+ clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
r.GET("/healthz", healthHandler.Healthz)
r.GET("/readyz", healthHandler.Readyz)
+ r.GET("/auth/.well-known/openid-configuration", oauthHandler.Discovery)
r.GET("/auth/oauth/authorize", oauthHandler.Authorize)
r.POST("/auth/oauth/token", oauthHandler.Token)
+ r.GET("/auth/oauth/jwks", oauthHandler.JWKS)
r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo)
v1 := r.Group("/auth/api/v1")
@@ -50,6 +53,7 @@ func New(deps Dependencies) *gin.Engine {
protected.GET("/wayen/login", wayenHandler.Login)
protected.GET("/wayen/credential", wayenHandler.GetCredential)
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
+ protected.GET("/clouddm/login", clouddmHandler.Login)
}
return r
diff --git a/authserver/internal/service/wayen.go b/authserver/internal/service/wayen.go
index a80797e..f370ab5 100644
--- a/authserver/internal/service/wayen.go
+++ b/authserver/internal/service/wayen.go
@@ -120,7 +120,7 @@ func (s *WayenService) oauthLoginURL(redirectURI, targetURL string) (string, err
next.Path = "/sign-in"
}
values := next.Query()
- values.Set("ref", "oauth")
+ values.Set("ref", defaultConfigValue(s.cfg.WayenOAuthRef, "/portal/namespace/1/app"))
next.RawQuery = values.Encode()
query := parsed.Query()
diff --git a/authserver/web/nginx.conf b/authserver/web/nginx.conf
index 8b78554..caaae48 100644
--- a/authserver/web/nginx.conf
+++ b/authserver/web/nginx.conf
@@ -53,6 +53,31 @@ server {
proxy_read_timeout 60s;
}
+ location /auth/.well-known/ {
+ proxy_pass http://authserver-backend:8083;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 5s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+ }
+
+ location /internal/clouddm/ {
+ proxy_pass http://open-cdm.db.svc.cluster.local:8222/;
+ proxy_http_version 1.1;
+ proxy_set_header Host 218.11.5.223:30009;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Host 218.11.5.223:30009;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto http;
+ proxy_connect_timeout 5s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+ }
+
location /auth/ {
try_files $uri $uri/ /auth/index.html;
}
diff --git a/authserver/web/src/App.vue b/authserver/web/src/App.vue
index a75ce29..be8f366 100644
--- a/authserver/web/src/App.vue
+++ b/authserver/web/src/App.vue
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue'
const TOKEN_KEY = 'authserver_token'
const SSO_LOGIN_PATH = '/auth/api/v1/login/internal-sso'
const WAYEN_LOGIN_PATH = '/auth/api/v1/wayen/login'
+const CLOUDDM_LOGIN_PATH = '/auth/api/v1/clouddm/login'
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const currentUser = ref(null)
@@ -11,6 +12,7 @@ const message = ref('')
const loading = ref(false)
const redirectingToSSO = ref(false)
const wayenLoading = ref(false)
+const clouddmLoading = ref(false)
const isAuthed = computed(() => Boolean(token.value))
const currentUserLabel = computed(() => {
@@ -26,20 +28,24 @@ function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
}
-function relayState(openWayen = false) {
+function relayState(openApp = '') {
const url = new URL(window.location.href)
- if (openWayen) {
+ url.searchParams.delete('open_wayen')
+ url.searchParams.delete('open_app')
+ if (openApp === 'wayen') {
url.searchParams.set('open_wayen', '1')
+ } else if (openApp) {
+ url.searchParams.set('open_app', openApp)
}
return `${url.pathname}${url.search}${url.hash}` || '/'
}
-function ssoLogin({ openWayen = false } = {}) {
+function ssoLogin({ openApp = '' } = {}) {
if (redirectingToSSO.value) {
return
}
redirectingToSSO.value = true
- window.location.assign(`${SSO_LOGIN_PATH}?relay_state=${encodeURIComponent(relayState(openWayen))}`)
+ window.location.assign(`${SSO_LOGIN_PATH}?relay_state=${encodeURIComponent(relayState(openApp))}`)
}
async function api(path, options = {}) {
@@ -55,7 +61,7 @@ async function api(path, options = {}) {
if (!response.ok) {
if (response.status === 401) {
clearAuth()
- ssoLogin({ openWayen: true })
+ ssoLogin()
}
const error = new Error(data.error || `HTTP ${response.status}`)
error.status = response.status
@@ -103,7 +109,7 @@ async function openWayen() {
if (!response.ok) {
if (response.status === 401) {
clearAuth()
- ssoLogin({ openWayen: true })
+ ssoLogin({ openApp: 'wayen' })
}
throw new Error(data.error || `HTTP ${response.status}`)
}
@@ -118,26 +124,59 @@ async function openWayen() {
}
}
+async function openCloudDM() {
+ clouddmLoading.value = true
+ message.value = ''
+ try {
+ const response = await fetch(CLOUDDM_LOGIN_PATH, {
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token.value}`,
+ },
+ credentials: 'include',
+ })
+ const data = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearAuth()
+ ssoLogin({ openApp: 'clouddm' })
+ }
+ throw new Error(data.error || `HTTP ${response.status}`)
+ }
+ if (!data.target_url) {
+ throw new Error('CloudDM 跳转地址为空')
+ }
+ window.location.assign(data.target_url)
+ } catch (error) {
+ message.value = error.message
+ } finally {
+ clouddmLoading.value = false
+ }
+}
+
onMounted(async () => {
const url = new URL(window.location.href)
const ssoToken = url.searchParams.get('sso_token')
- const shouldOpenWayen = url.searchParams.get('open_wayen') === '1'
+ const openApp = url.searchParams.get('open_wayen') === '1' ? 'wayen' : url.searchParams.get('open_app')
if (ssoToken) {
token.value = ssoToken
localStorage.setItem(TOKEN_KEY, ssoToken)
url.searchParams.delete('sso_token')
url.searchParams.delete('open_wayen')
+ url.searchParams.delete('open_app')
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
}
if (!token.value) {
- ssoLogin({ openWayen: true })
+ ssoLogin()
return
}
await run(async () => {
await loadMe()
}, '已就绪')
- if (shouldOpenWayen) {
+ if (openApp === 'wayen') {
await openWayen()
+ } else if (openApp === 'clouddm') {
+ await openCloudDM()
}
})
@@ -147,17 +186,17 @@ onMounted(async () => {
AuthServer
-
Wayen 登录入口
+
应用入口
正在跳转到 SSO 登录...
-
-