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 登录...

-
-
+
+

AuthServer

-

Wayen

+

应用入口

{{ currentUserLabel }} @@ -165,10 +204,14 @@ onMounted(async () => {
-
- +

{{ message }}

diff --git a/authserver/web/src/style.css b/authserver/web/src/style.css index 6c8cffd..9283195 100644 --- a/authserver/web/src/style.css +++ b/authserver/web/src/style.css @@ -98,7 +98,7 @@ h1 { font-size: 13px; } -.wayen-page { +.app-page { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; @@ -107,7 +107,7 @@ h1 { #f5f7fb; } -.wayen-header { +.app-header { display: flex; align-items: center; justify-content: space-between; @@ -118,7 +118,7 @@ h1 { backdrop-filter: blur(10px); } -.wayen-main { +.app-main { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 360px)); justify-content: center; @@ -127,7 +127,7 @@ h1 { padding: 32px 20px; } -.wayen-button { +.app-button { width: min(280px, 78vw); aspect-ratio: 1; display: grid; @@ -140,27 +140,34 @@ h1 { box-shadow: 0 18px 50px rgb(43 57 84 / 12%); } -.wayen-button:hover:not(:disabled), -.wayen-button:focus-visible { +.app-button:hover:not(:disabled), +.app-button:focus-visible { border-color: #1b64d8; box-shadow: 0 22px 60px rgb(27 100 216 / 18%); outline: none; } -.wayen-icon { +.app-icon { width: 112px; height: 112px; display: grid; place-items: center; border-radius: 28px; - background: #1b64d8; color: #ffffff; font-size: 64px; font-weight: 800; line-height: 1; } -.wayen-title { +.wayen-icon { + background: #1b64d8; +} + +.clouddm-icon { + background: #0f7b6c; +} + +.app-title { color: #172033; font-size: 22px; font-weight: 750; @@ -175,7 +182,7 @@ h1 { } @media (max-width: 560px) { - .wayen-header { + .app-header { align-items: flex-start; flex-direction: column; padding: 18px 20px; @@ -186,14 +193,14 @@ h1 { justify-content: space-between; } - .wayen-icon { + .app-icon { width: 92px; height: 92px; border-radius: 22px; font-size: 52px; } - .wayen-main { + .app-main { grid-template-columns: 1fr; } }