Skip to content

Commit bfc17bc

Browse files
swordqiuQiu Jian
andauthored
feature: assume user login support (#23964)
Co-authored-by: Qiu Jian <[email protected]>
1 parent 9434522 commit bfc17bc

File tree

8 files changed

+279
-8
lines changed

8 files changed

+279
-8
lines changed

pkg/apigateway/clientman/clientman.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ package clientman
1717
import (
1818
"crypto/rand"
1919
"crypto/rsa"
20-
"io/ioutil"
20+
"os"
2121

2222
"github.com/pkg/errors"
2323

@@ -27,9 +27,9 @@ import (
2727

2828
func InitClient() error {
2929
if options.Options.EnableSsl {
30-
privData, err := ioutil.ReadFile(options.Options.SslKeyfile)
30+
privData, err := os.ReadFile(options.Options.SslKeyfile)
3131
if err != nil {
32-
return errors.Wrapf(err, "ioutil.ReadFile %s", options.Options.SslKeyfile)
32+
return errors.Wrapf(err, "os.ReadFile %s", options.Options.SslKeyfile)
3333
}
3434
privateKey, err := seclib2.DecodePrivateKey(privData)
3535
if err != nil {

pkg/apigateway/handler/assume.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2019 Yunion
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package handler
16+
17+
import (
18+
"context"
19+
"net/http"
20+
"strings"
21+
22+
"yunion.io/x/onecloud/pkg/apigateway/clientman"
23+
"yunion.io/x/onecloud/pkg/appsrv"
24+
"yunion.io/x/onecloud/pkg/httperrors"
25+
"yunion.io/x/onecloud/pkg/mcclient"
26+
"yunion.io/x/onecloud/pkg/mcclient/auth"
27+
"yunion.io/x/onecloud/pkg/util/netutils2"
28+
"yunion.io/x/pkg/errors"
29+
)
30+
31+
const (
32+
ASSUME_TOKEN_HEADER = "X-Assume-Token"
33+
)
34+
35+
type SAssumeToken struct {
36+
Token string `json:"token"`
37+
UserId string `json:"user_id"`
38+
ProjectId string `json:"project_id"`
39+
}
40+
41+
func getAssumeToken(r *http.Request) (*SAssumeToken, error) {
42+
auth := r.Header.Get(ASSUME_TOKEN_HEADER)
43+
if auth == "" {
44+
return nil, errors.Wrap(httperrors.ErrInputParameter, "Assume token header is empty")
45+
}
46+
parts := strings.Split(strings.TrimSpace(auth), ":")
47+
if len(parts) != 3 {
48+
return nil, errors.Wrap(httperrors.ErrInputParameter, "Assume token header is invalid")
49+
}
50+
return &SAssumeToken{
51+
Token: parts[0],
52+
UserId: parts[1],
53+
ProjectId: parts[2],
54+
}, nil
55+
}
56+
57+
func doAssumeLogin(assumeToken *SAssumeToken, w http.ResponseWriter, req *http.Request) (mcclient.TokenCredential, error) {
58+
cliIp := netutils2.GetHttpRequestIp(req)
59+
token, err := auth.Client().AuthenticateAssume(assumeToken.Token, assumeToken.UserId, assumeToken.ProjectId, cliIp)
60+
if err != nil {
61+
return nil, errors.Wrapf(httperrors.ErrInvalidCredential, "AuthenticateAssume fail %s", err)
62+
}
63+
authToken := clientman.NewAuthToken(token.GetTokenString(), false, false, false)
64+
saveLoginCookies(w, authToken, token, nil)
65+
return token, nil
66+
}
67+
68+
func (h *AuthHandlers) handleAssumeLogin(ctx context.Context, w http.ResponseWriter, req *http.Request) {
69+
// no auth cookie, try to get get assume user token from header
70+
assumeToken, err := getAssumeToken(req)
71+
if err != nil {
72+
appsrv.Send(w, err.Error())
73+
return
74+
}
75+
token, err := doAssumeLogin(assumeToken, w, req)
76+
if err != nil {
77+
appsrv.Send(w, err.Error())
78+
return
79+
}
80+
_, query, _ := appsrv.FetchEnv(ctx, w, req)
81+
redirect := ""
82+
if query != nil {
83+
queryMap, err := query.GetMap()
84+
if err != nil {
85+
appsrv.Send(w, err.Error())
86+
return
87+
}
88+
for k, v := range queryMap {
89+
if k == "p" {
90+
redirect, _ = v.GetString()
91+
} else {
92+
value, _ := v.GetString()
93+
saveCookie(w, k, value, "", token.GetExpires(), false)
94+
}
95+
}
96+
}
97+
if !strings.HasPrefix(redirect, "/") {
98+
redirect = "/" + redirect
99+
}
100+
appsrv.SendRedirect(w, redirect)
101+
}

pkg/apigateway/handler/auth.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func (h *AuthHandlers) AddMethods() {
8585
NewHP(handleOIDCJWKeys, "oidc", "keys"),
8686
NewHP(handleOIDCUserInfo, "oidc", "user"),
8787
NewHP(handleOIDCRPInitLogout, "oidc", "logout"),
88+
NewHP(h.handleAssumeLogin, "assume"),
8889
)
8990
h.AddByMethod(POST, nil,
9091
NewHP(h.initTotpSecrets, "initcredential"),
@@ -661,31 +662,34 @@ func (h *AuthHandlers) doLogin(ctx context.Context, w http.ResponseWriter, req *
661662
return httperrors.NewForbiddenError("user forbidden login from web")
662663
}
663664

665+
saveLoginCookies(w, authToken, token, body)
666+
return nil
667+
}
668+
669+
func saveLoginCookies(w http.ResponseWriter, authToken *clientman.SAuthToken, token mcclient.TokenCredential, body jsonutils.JSONObject) {
664670
saveAuthCookie(w, authToken, token)
665671

666672
if len(token.GetProjectId()) > 0 {
667-
if body.Contains("isadmin") {
673+
if body != nil && body.Contains("isadmin") {
668674
adminVal := "false"
669675
if policy.PolicyManager.IsScopeCapable(token, rbacscope.ScopeSystem) {
670676
adminVal, _ = body.GetString("isadmin")
671677
}
672678
saveCookie(w, "isadmin", adminVal, "", token.GetExpires(), false)
673679
}
674-
if body.Contains("scope") {
680+
if body != nil && body.Contains("scope") {
675681
scopeStr, _ := body.GetString("scope")
676682
if !policy.PolicyManager.IsScopeCapable(token, rbacscope.TRbacScope(scopeStr)) {
677683
scopeStr = string(rbacscope.ScopeProject)
678684
}
679685
saveCookie(w, "scope", scopeStr, "", token.GetExpires(), false)
680686
}
681-
if body.Contains("domain") {
687+
if body != nil && body.Contains("domain") {
682688
domainStr, _ := body.GetString("domain")
683689
saveCookie(w, "domain", domainStr, "", token.GetExpires(), false)
684690
}
685691
saveCookie(w, "tenant", token.GetProjectId(), "", token.GetExpires(), false)
686692
}
687-
688-
return nil
689693
}
690694

691695
func doLogout(ctx context.Context, w http.ResponseWriter, req *http.Request) {

pkg/apis/identity/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const (
3838
AUTH_METHOD_OIDC = "oidc"
3939
AUTH_METHOD_OAuth2 = "oauth2"
4040
AUTH_METHOD_VERIFY = "verify"
41+
AUTH_METHOD_ASSUME = "assume"
4142

4243
// AUTH_METHOD_ID_PASSWORD = 1
4344
// AUTH_METHOD_ID_TOKEN = 2

pkg/keystone/tokens/assume.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2019 Yunion
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package tokens
16+
17+
import (
18+
"context"
19+
20+
"yunion.io/x/pkg/errors"
21+
"yunion.io/x/pkg/util/rbacscope"
22+
23+
api "yunion.io/x/onecloud/pkg/apis/identity"
24+
"yunion.io/x/onecloud/pkg/cloudcommon/policy"
25+
"yunion.io/x/onecloud/pkg/httperrors"
26+
"yunion.io/x/onecloud/pkg/keystone/models"
27+
"yunion.io/x/onecloud/pkg/mcclient"
28+
)
29+
30+
// authUserByAssume allows an admin token to login as any user without password
31+
func authUserByAssume(ctx context.Context, input mcclient.SAuthenticationInputV3) (*api.SUserExtended, error) {
32+
// Validate admin token
33+
if len(input.Auth.Identity.Token.Id) == 0 {
34+
return nil, httperrors.NewInputParameterError("admin token is required for assume authentication")
35+
}
36+
37+
// Decode and validate the admin token
38+
adminToken, err := TokenStrDecode(ctx, input.Auth.Identity.Token.Id)
39+
if err != nil {
40+
return nil, errors.Wrap(err, "decode admin token")
41+
}
42+
43+
// Check if admin token is expired
44+
if adminToken.IsExpired() {
45+
return nil, ErrExpiredToken
46+
}
47+
48+
scopedProject, err := models.ProjectManager.FetchProject(
49+
input.Auth.Scope.Project.Id,
50+
input.Auth.Scope.Project.Name,
51+
input.Auth.Scope.Project.Domain.Id,
52+
input.Auth.Scope.Project.Domain.Name,
53+
)
54+
if err != nil {
55+
return nil, errors.Wrap(err, "fetch scoped project")
56+
}
57+
58+
var requireScope rbacscope.TRbacScope
59+
if adminToken.ProjectId == scopedProject.Id {
60+
requireScope = rbacscope.ScopeProject
61+
} else if adminToken.DomainId == scopedProject.DomainId {
62+
requireScope = rbacscope.ScopeDomain
63+
} else {
64+
requireScope = rbacscope.ScopeSystem
65+
}
66+
67+
adminToken.ProjectId = scopedProject.Id
68+
adminToken.DomainId = scopedProject.DomainId
69+
70+
adminTokenCred, err := adminToken.GetSimpleUserCred(input.Auth.Identity.Token.Id)
71+
if err != nil {
72+
return nil, errors.Wrap(err, "get admin token credential")
73+
}
74+
75+
// Validate target user information
76+
assumeUser := input.Auth.Identity.Assume.User
77+
if len(assumeUser.Id) == 0 && len(assumeUser.Name) == 0 {
78+
return nil, httperrors.NewInputParameterError("target user id or name is required")
79+
}
80+
81+
// Fetch target user
82+
targetUser, err := models.UserManager.FetchUserExtended(
83+
assumeUser.Id,
84+
assumeUser.Name,
85+
assumeUser.Domain.Id,
86+
assumeUser.Domain.Name,
87+
)
88+
if err != nil {
89+
return nil, errors.Wrap(err, "fetch target user")
90+
}
91+
92+
if adminTokenCred.GetUserId() != targetUser.Id {
93+
if policy.PolicyManager.Allow(requireScope, adminTokenCred, api.SERVICE_TYPE, "tokens", "perform", "assume").Result.IsDeny() {
94+
return nil, httperrors.NewForbiddenError("%s not allow to assume user in project %s", adminTokenCred.GetUserName(), scopedProject.Name)
95+
}
96+
}
97+
98+
// Set audit IDs to include both admin token and assume operation
99+
targetUser.AuditIds = []string{adminTokenCred.GetUserId()}
100+
101+
return targetUser, nil
102+
}

pkg/keystone/tokens/auth.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,11 @@ func AuthenticateV3(ctx context.Context, input mcclient.SAuthenticationInputV3)
485485
if err != nil {
486486
return nil, errors.Wrap(err, "authUserByVerify")
487487
}
488+
case api.AUTH_METHOD_ASSUME:
489+
user, err = authUserByAssume(ctx, input)
490+
if err != nil {
491+
return nil, errors.Wrap(err, "authUserByAssume")
492+
}
488493
default:
489494
// auth by other methods, e.g. password , etc...
490495
user, err = authUserByIdentityV3(ctx, input)

pkg/mcclient/assume.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2019 Yunion
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mcclient
16+
17+
import (
18+
api "yunion.io/x/onecloud/pkg/apis/identity"
19+
"yunion.io/x/onecloud/pkg/httperrors"
20+
)
21+
22+
func (client *Client) AuthenticateAssume(token string, userId, projectId string, cliIp string) (TokenCredential, error) {
23+
aCtx := SAuthContext{
24+
// Assume auth must comes from API
25+
Source: AuthSourceAPI,
26+
Ip: cliIp,
27+
}
28+
return client.authenticateAssumeWithContext(token, userId, projectId, aCtx)
29+
}
30+
31+
func (client *Client) authenticateAssumeWithContext(token string, userId, projectId string, aCtx SAuthContext) (TokenCredential, error) {
32+
if client.AuthVersion() != "v3" {
33+
return nil, httperrors.ErrNotSupported
34+
}
35+
input := SAuthenticationInputV3{}
36+
input.Auth.Identity.Token.Id = token
37+
input.Auth.Identity.Methods = []string{api.AUTH_METHOD_ASSUME}
38+
input.Auth.Identity.Assume.User.Id = userId
39+
input.Auth.Scope.Project.Id = projectId
40+
input.Auth.Context = aCtx
41+
return client._authV3Input(input)
42+
}

pkg/mcclient/input.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,22 @@ type SAuthenticationIdentity struct {
134134
VerifyCode string `json:"verify_code,omitempty"`
135135
ContactType string `json:"contact_type,omitempty"`
136136
} `json:"mobile,omitempty"`
137+
// 当认证方式为assume时,通过该字段提供目标用户信息
138+
Assume struct {
139+
User struct {
140+
// 用户ID
141+
Id string `json:"id,omitempty"`
142+
// 用户名称
143+
Name string `json:"name,omitempty"`
144+
// 用户所属域的信息
145+
Domain struct {
146+
// 域ID
147+
Id string `json:"id,omitempty"`
148+
// 域名称
149+
Name string `json:"name,omitempty"`
150+
} `json:"domain,omitempty"`
151+
} `json:"user,omitempty"`
152+
} `json:"assume,omitempty"`
137153
}
138154

139155
type SAuthenticationInputV3 struct {

0 commit comments

Comments
 (0)