sync authserver sso integrations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 配置:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user