sync authserver sso integrations

This commit is contained in:
mac
2026-07-14 17:37:08 +08:00
parent aa06c05d88
commit 8c7a1ae9ea
12 changed files with 490 additions and 43 deletions
+6
View File
@@ -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
+17
View File
@@ -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 配置:
+46 -1
View File
@@ -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 {
+64
View File
@@ -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])
}
+64
View File
@@ -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 == "" {
+111
View File
@@ -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,
})
}
+73 -12
View File
@@ -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 != ""
+4
View File
@@ -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
+1 -1
View File
@@ -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()
+25
View File
@@ -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;
}
+60 -17
View File
@@ -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()
}
})
</script>
@@ -147,17 +186,17 @@ onMounted(async () => {
<section class="login-panel">
<div>
<p class="eyebrow">AuthServer</p>
<h1>Wayen 登录入口</h1>
<h1>应用入口</h1>
</div>
<p class="message">正在跳转到 SSO 登录...</p>
</section>
</main>
<main v-else class="wayen-page">
<header class="wayen-header">
<main v-else class="app-page">
<header class="app-header">
<div>
<p class="eyebrow">AuthServer</p>
<h1>Wayen</h1>
<h1>应用入口</h1>
</div>
<div class="user-block">
<span>{{ currentUserLabel }}</span>
@@ -165,10 +204,14 @@ onMounted(async () => {
</div>
</header>
<section class="wayen-main">
<button class="wayen-button" type="button" :disabled="wayenLoading" @click="openWayen">
<span class="wayen-icon" aria-hidden="true">W</span>
<span class="wayen-title">Wayen</span>
<section class="app-main">
<button class="app-button wayen-button" type="button" :disabled="wayenLoading" @click="openWayen">
<span class="app-icon wayen-icon" aria-hidden="true">W</span>
<span class="app-title">Wayen</span>
</button>
<button class="app-button clouddm-button" type="button" :disabled="clouddmLoading" @click="openCloudDM">
<span class="app-icon clouddm-icon" aria-hidden="true">C</span>
<span class="app-title">CloudDM</span>
</button>
<p v-if="message" class="message">{{ message }}</p>
</section>
+19 -12
View File
@@ -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;
}
}