From 45e6b9ef1f1a214c377ebaa4bb621550721f8f38 Mon Sep 17 00:00:00 2001 From: powerfool Date: Thu, 15 Aug 2024 17:42:21 +0800 Subject: [PATCH] Fixed some problems in access control testing (#505) --- .../oceanbase-dashboard/templates/NOTES.txt | 19 +- .../templates/user-credentials.yaml | 2 +- charts/oceanbase-dashboard/values.yaml | 4 +- internal/dashboard/business/ac/account.go | 166 +++++++++++++----- .../dashboard/business/ac/account_test.go | 7 + internal/dashboard/business/ac/role.go | 22 ++- .../business/oceanbase/obcluster_enforcer.go | 4 + internal/dashboard/generated/swagger/docs.go | 87 +++++++++ .../dashboard/generated/swagger/swagger.json | 87 +++++++++ .../dashboard/generated/swagger/swagger.yaml | 53 ++++++ internal/dashboard/handler/ac_handler.go | 96 +++++++++- internal/dashboard/handler/user_handler.go | 7 +- internal/dashboard/model/ac/internal.go | 60 +++++++ internal/dashboard/model/ac/response.go | 1 + internal/dashboard/model/param/login_param.go | 5 + internal/dashboard/router/v1/ac_router.go | 3 +- internal/dashboard/router/v1/info_router.go | 2 +- .../dashboard/router/v1/obtenant_router.go | 2 +- 18 files changed, 562 insertions(+), 65 deletions(-) create mode 100644 internal/dashboard/model/ac/internal.go diff --git a/charts/oceanbase-dashboard/templates/NOTES.txt b/charts/oceanbase-dashboard/templates/NOTES.txt index d58b89a07..1c23b559d 100644 --- a/charts/oceanbase-dashboard/templates/NOTES.txt +++ b/charts/oceanbase-dashboard/templates/NOTES.txt @@ -1,3 +1,4 @@ +Welcome to OceanBase dashboard! ___ ____ / _ \ ___ ___ __ _ _ __ | __ ) __ _ ___ ___ | | | |/ __/ _ \/ _` | '_ \| _ \ / _` / __|/ _ \ @@ -10,18 +11,22 @@ | |_| | (_| \__ \ | | | |_) | (_) | (_| | | | (_| | |____/ \__,_|___/_| |_|_.__/ \___/ \__,_|_| \__,_| -Welcome to OceanBase dashboard! -1. After installing the dashboard chart, you can use `port-forward` to expose the dashboard outside like: +1. [Temporary accessing] After installing the dashboard chart, you can use `port-forward` to expose the dashboard outside as following command: > kubectl port-forward -n {{ .Release.Namespace }} services/oceanbase-dashboard-{{ .Release.Name }} 18081:80 --address 0.0.0.0 -then you can visit the dashboard on http://$YOUR_SERVER_IP:18081 +then you can visit the dashboard on http://$YOUR_SERVER_IP:18081 (Take 18081 as an example here) + +2. For security reason, it is recommended to use a service to access the dashboard. By default the oceanbase-dashboard helm chart creates a service of type NodePort. + You can use the following command to get the nodePort of the dashboard service: + + > export SERVICE_PORT_PORT=$(kubectl get -n {{ .Release.Namespace }} secret/oceanbase-dashboard-{{ .Release.Name }} -o jsonpath="{.spec.ports[?(@.name=='dashboard-backend')].nodePort}") -2. Use the following command to get password for default admin user +then you can visit the dashboard on http://$YOUR_SERVER_IP:$SERVICE_PORT_PORT - > echo $(kubectl get -n {{ .Release.Namespace }} secret {{ .Release.Name }}-user-credentials -o jsonpath='{.data.admin}' | base64 -d) +3. Login the dashboard with the following default account. + For users that log in for the first time, it is required to reset the password. -Log in as default account: Username: admin -Password: \ No newline at end of file +Password: \ No newline at end of file diff --git a/charts/oceanbase-dashboard/templates/user-credentials.yaml b/charts/oceanbase-dashboard/templates/user-credentials.yaml index 08f59c747..c775c5bde 100644 --- a/charts/oceanbase-dashboard/templates/user-credentials.yaml +++ b/charts/oceanbase-dashboard/templates/user-credentials.yaml @@ -8,7 +8,7 @@ metadata: {{- include "oceanbase-dashboard.labels" . | nindent 4 }} data: {{- if empty (lookup "v1" "Secret" $.Release.Namespace (nospace (cat $.Release.Name "-user-credentials"))) }} - admin: {{ cat (sha256sum (.Values.adminPassword | default (randAlphaNum 16))) "admin" "0" "super admin" | b64enc }} + admin: {{ cat (sha256sum (.Values.adminPassword | default ("admin"))) "<|:SEP:|> admin <|:SEP:|> 0 <|:SEP:|> super admin" | b64enc }} {{- else }} admin: {{ (lookup "v1" "Secret" $.Release.Namespace (nospace (cat $.Release.Name "-user-credentials"))).data.admin }} {{- end }} diff --git a/charts/oceanbase-dashboard/values.yaml b/charts/oceanbase-dashboard/values.yaml index 4b3ba82c6..b27220b8e 100644 --- a/charts/oceanbase-dashboard/values.yaml +++ b/charts/oceanbase-dashboard/values.yaml @@ -1,6 +1,6 @@ initCredentials: true -# base64 encoded password. If not set, the chart will generate it randomly -adminPassword: +# admin's password. If not set, the chart will generate it randomly +adminPassword: admin userCredentials: userNamespace: diff --git a/internal/dashboard/business/ac/account.go b/internal/dashboard/business/ac/account.go index 677179f64..bf5b63b4d 100644 --- a/internal/dashboard/business/ac/account.go +++ b/internal/dashboard/business/ac/account.go @@ -17,7 +17,7 @@ import ( "crypto/sha256" "encoding/hex" "os" - "strconv" + "sort" "strings" "time" @@ -26,6 +26,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" acmodel "github.com/oceanbase/ob-operator/internal/dashboard/model/ac" + "github.com/oceanbase/ob-operator/internal/dashboard/model/param" httpErr "github.com/oceanbase/ob-operator/pkg/errors" "github.com/oceanbase/ob-operator/pkg/k8s/client" ) @@ -57,7 +58,7 @@ func GetAccount(ctx context.Context, username string) (*acmodel.Account, error) } func Enforce(_ context.Context, username string, policy *acmodel.Policy) (bool, error) { - return enforcer.Enforce(username, policy.Object, policy.Action) + return enforcer.Enforce(username, policy.ComposeDomainObject(), string(policy.Action)) } func ValidateAccount(ctx context.Context, username, password string) (*acmodel.Account, error) { @@ -81,15 +82,66 @@ func ValidateAccount(ctx context.Context, username, password string) (*acmodel.A account.Roles = roles } - now := time.Now().Unix() - err = updateUserCredentials(ctx, credentials, username, account.password, account.Nickname, strconv.FormatInt(now, 10), account.Description) - if err != nil { - logrus.WithError(err).Warn("failed to update user credentials") + if account.LastLoginAt != nil && account.LastLoginAt.Unix() != 0 { + // The account has logged in before + enforcer.accMu.Lock() + defer enforcer.accMu.Unlock() + // Update latest login time + now := time.Now().Unix() + up := &acmodel.UpdateAccountCreds{ + Username: username, + AccountCreds: acmodel.AccountCreds{ + EncryptedPassword: account.password, + Nickname: account.Nickname, + LastLoginAtUnix: now, + Description: account.Description, + }, + } + err = updateUserCredentials(ctx, credentials, up) + if err != nil { + logrus.WithError(err).Warn("failed to update user credentials") + } } return &account.Account, nil } +// ResetAccountPassword resets account's own password. +func ResetAccountPassword(ctx context.Context, username string, resetParam *param.ResetPasswordParam) (*acmodel.Account, error) { + credentials, err := getDashboardUserCredentials(ctx) + if err != nil { + return nil, err + } + account, err := fetchAccount(credentials, username) + if err != nil { + return nil, err + } + if account.LastLoginAt != nil && account.LastLoginAt.Unix() != 0 { + bts := sha256.Sum256([]byte(resetParam.OldPassword)) + sha256EncodedPwd := hex.EncodeToString(bts[:]) + if account.password != sha256EncodedPwd { + return nil, httpErr.NewBadRequest("password is incorrect") + } + } + newBts := sha256.Sum256([]byte(resetParam.Password)) + newEncryptedPwd := hex.EncodeToString(newBts[:]) + + up := &acmodel.UpdateAccountCreds{ + Username: username, + AccountCreds: acmodel.AccountCreds{ + EncryptedPassword: newEncryptedPwd, + Nickname: account.Nickname, + LastLoginAtUnix: time.Now().Unix(), + Description: account.Description, + }, + } + err = updateUserCredentials(ctx, credentials, up) + if err != nil { + return nil, httpErr.NewInternal("failed to update user credentials") + } + return nil, nil +} + func CreateAccount(ctx context.Context, param *acmodel.CreateAccountParam) (*acmodel.Account, error) { enforcer.accMu.Lock() defer enforcer.accMu.Unlock() @@ -101,14 +153,14 @@ func CreateAccount(ctx context.Context, param *acmodel.CreateAccountParam) (*acm return nil, httpErr.NewBadRequest("username already exists") } - roles, err := enforcer.GetFilteredPolicy(0, param.Roles...) - if err != nil { - return nil, err - } - if len(roles) != len(param.Roles) { - return nil, httpErr.NewBadRequest("role does not exist") - } for _, role := range param.Roles { + policies, err := enforcer.GetFilteredPolicy(0, role) + if err != nil { + return nil, err + } + if len(policies) == 0 { + return nil, httpErr.NewBadRequest("role does not exist: " + role) + } ok, err := enforcer.AddRoleForUser(param.Username, role) if err != nil { return nil, err @@ -119,7 +171,15 @@ func CreateAccount(ctx context.Context, param *acmodel.CreateAccountParam) (*acm } bts := sha256.Sum256([]byte(param.Password)) sha256EncodedPwd := hex.EncodeToString(bts[:]) - err = updateUserCredentials(ctx, credentials, param.Username, sha256EncodedPwd, param.Nickname, "0", param.Description) + up := &acmodel.UpdateAccountCreds{ + Username: param.Username, + AccountCreds: acmodel.AccountCreds{ + EncryptedPassword: sha256EncodedPwd, + Nickname: param.Nickname, + Description: param.Description, + }, + } + err = updateUserCredentials(ctx, credentials, up) if err != nil { return nil, httpErr.NewInternal("failed to update user credentials") } @@ -146,9 +206,11 @@ func PatchAccount(ctx context.Context, username string, param *acmodel.PatchAcco return nil, err } if len(param.Roles) > 0 { - _, err := enforcer.GetFilteredPolicy(0, param.Roles...) - if err != nil { - return nil, err + for _, role := range param.Roles { + _, err := enforcer.GetFilteredPolicy(0, role) + if err != nil { + return nil, httpErr.NewBadRequest("role does not exist: " + role) + } } for _, role := range acc.Roles { ok, err := enforcer.DeleteRoleForUser(username, role.Name) @@ -171,8 +233,9 @@ func PatchAccount(ctx context.Context, username string, param *acmodel.PatchAcco } accountChanged := false - if param.Password != "" && acc.password != param.Password { - acc.password = param.Password + if param.Password != "" { + bts := sha256.Sum256([]byte(param.Password)) + acc.password = hex.EncodeToString(bts[:]) accountChanged = true } if param.Nickname != "" && acc.Nickname != param.Nickname { @@ -184,7 +247,16 @@ func PatchAccount(ctx context.Context, username string, param *acmodel.PatchAcco accountChanged = true } if accountChanged { - err = updateUserCredentials(ctx, credentials, username, acc.password, acc.Nickname, strconv.FormatInt(time.Now().Unix(), 10), acc.Description) + up := &acmodel.UpdateAccountCreds{ + Username: username, + AccountCreds: acmodel.AccountCreds{ + EncryptedPassword: acc.password, + Nickname: acc.Nickname, + LastLoginAtUnix: acc.LastLoginAt.Unix(), + Description: acc.Description, + }, + } + err = updateUserCredentials(ctx, credentials, up) if err != nil { return nil, httpErr.NewInternal("failed to update user credentials") } @@ -220,7 +292,10 @@ func DeleteAccount(ctx context.Context, username string) (*acmodel.Account, erro if err != nil { return nil, err } - err = updateUserCredentials(ctx, credentials, username, "", "", "0", "") + err = updateUserCredentials(ctx, credentials, &acmodel.UpdateAccountCreds{ + Username: username, + Delete: true, + }) if err != nil { return nil, httpErr.NewInternal("failed to update user credentials") } @@ -240,31 +315,26 @@ func getDashboardUserCredentials(c context.Context) (*v1.Secret, error) { return clt.ClientSet.CoreV1().Secrets(ns).Get(c, secretName, metav1.GetOptions{}) } -func updateUserCredentials(c context.Context, credentials *v1.Secret, username, password, nickname, lastLoginAtUnix, description string) error { - if password == "" { - delete(credentials.Data, username) +func updateUserCredentials(c context.Context, credentials *v1.Secret, up *acmodel.UpdateAccountCreds) error { + if up.Delete { + delete(credentials.Data, up.Username) } else { - credentials.Data[username] = []byte(strings.Join([]string{password, nickname, lastLoginAtUnix, description}, " ")) + credentials.Data[up.Username] = []byte(up.ToLine()) } clt := client.GetClient() _, err := clt.ClientSet.CoreV1().Secrets(os.Getenv("USER_NAMESPACE")).Update(c, credentials, metav1.UpdateOptions{}) return err } -// pwd nickname lastLogin description func fetchAccount(credentials *v1.Secret, username string) (*account, error) { if _, ok := credentials.Data[username]; !ok { return nil, httpErr.NewBadRequest("username or password is incorrect") } infoLine := string(credentials.Data[username]) - parts := strings.SplitN(infoLine, " ", 4) - if len(parts) != 4 { - return nil, httpErr.NewInternal("User credentials file is corrupted: invalid format") - } - ts, err := strconv.ParseInt(parts[2], 10, 64) + creds, err := acmodel.NewAccountCreds(infoLine) if err != nil { - return nil, httpErr.NewInternal("User credentials file is corrupted: last login time is not a valid timestamp") + return nil, httpErr.NewInternal(err.Error()) } roles, err := getAccountRoles(username) if err != nil { @@ -273,16 +343,22 @@ func fetchAccount(credentials *v1.Secret, username string) (*account, error) { if len(roles) == 0 { return nil, httpErr.NewInternal("User credentials file is corrupted: user has no role") } - lastLoginAt := time.Unix(ts, 0) + var lastLoginAt *time.Time + if creds.LastLoginAtUnix != 0 { + tmpNow := time.Unix(creds.LastLoginAtUnix, 0) + lastLoginAt = &tmpNow + } + needReset := creds.LastLoginAtUnix == 0 return &account{ Account: acmodel.Account{ Username: username, - Nickname: parts[1], - LastLoginAt: &lastLoginAt, - Description: parts[3], + Nickname: creds.Nickname, + LastLoginAt: lastLoginAt, + Description: creds.Description, Roles: roles, + NeedReset: needReset, }, - password: parts[0], + password: creds.EncryptedPassword, }, nil } @@ -299,6 +375,9 @@ func fetchAccounts(ctx context.Context) ([]acmodel.Account, error) { } accounts = append(accounts, account.Account) } + sort.SliceStable(accounts, func(i, j int) bool { + return accounts[i].Username < accounts[j].Username + }) return accounts, nil } @@ -318,10 +397,15 @@ func getAccountRoles(username string) ([]acmodel.Role, error) { } policies := make([]acmodel.Policy, 0, len(policyLines)) for _, line := range policyLines { - policies = append(policies, acmodel.Policy{ - Object: acmodel.Object(line[1]), - Action: acmodel.Action(line[2]), - }) + if line[1] == "*" { + policies = append(policies, acmodel.NewPolicy("*", "*", line[2])) + } else { + parts := strings.Split(line[1], "/") + if len(parts) != 2 { + return nil, httpErr.NewInternal("corrupted policy" + strings.Join(line, " ")) + } + policies = append(policies, acmodel.NewPolicy(parts[0], parts[1], line[2])) + } } modelRoles = append(modelRoles, acmodel.Role{ Name: role, diff --git a/internal/dashboard/business/ac/account_test.go b/internal/dashboard/business/ac/account_test.go index 0a81efc96..0cb14219e 100644 --- a/internal/dashboard/business/ac/account_test.go +++ b/internal/dashboard/business/ac/account_test.go @@ -22,6 +22,13 @@ import ( ) var _ = Describe("Access Control", Ordered, ContinueOnFailure, func() { + It("GetFilteredPolicies", func() { + roles, err := enforcer.GetFilteredPolicy(0, "admin") + Expect(err).To(BeNil()) + Expect(roles).To(HaveLen(1)) + GinkgoLogr.Info("roles", "roles", roles) + }) + It("GetPolicies", func() { ps, err := enforcer.GetPolicy() Expect(err).To(BeNil()) diff --git a/internal/dashboard/business/ac/role.go b/internal/dashboard/business/ac/role.go index 2d56edfda..d6e91c9fb 100644 --- a/internal/dashboard/business/ac/role.go +++ b/internal/dashboard/business/ac/role.go @@ -15,6 +15,7 @@ package ac import ( "context" "os" + "sort" "strings" corev1 "k8s.io/api/core/v1" @@ -81,6 +82,9 @@ func ListRoles(_ context.Context) ([]*acmodel.Role, error) { role.Policies = append(role.Policies, acmodel.NewPolicy(parts[0], parts[1], p[2])) } } + sort.Slice(roles, func(i, j int) bool { + return roles[i].Name < roles[j].Name + }) return roles, nil } @@ -199,14 +203,16 @@ func persistPolicies(ctx context.Context, targetFile string, extra ...string) er return err } } - file, err := os.OpenFile(targetFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return err - } - defer file.Close() - _, err = file.WriteString(csv) - if err != nil { - return err + if os.Getenv("DEBUG_ACCESS_CONTROL") == "true" { + file, err := os.OpenFile(targetFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString(csv) + if err != nil { + return err + } } return nil } diff --git a/internal/dashboard/business/oceanbase/obcluster_enforcer.go b/internal/dashboard/business/oceanbase/obcluster_enforcer.go index b0d4f07cb..6927bf11e 100644 --- a/internal/dashboard/business/oceanbase/obcluster_enforcer.go +++ b/internal/dashboard/business/oceanbase/obcluster_enforcer.go @@ -15,6 +15,8 @@ package oceanbase import ( "context" + "github.com/sirupsen/logrus" + "github.com/oceanbase/ob-operator/api/v1alpha1" acbiz "github.com/oceanbase/ob-operator/internal/dashboard/business/ac" acmodel "github.com/oceanbase/ob-operator/internal/dashboard/model/ac" @@ -30,8 +32,10 @@ func filterClusters(username, action string, list *v1alpha1.OBClusterList) *v1al Action: acmodel.Action(action), }) if err != nil { + logrus.Error(err) continue } + logrus.Debugf("enforce user %s for cluster %s is %t", username, c.Name, ok) if ok { newList = append(newList, list.Items[i]) } diff --git a/internal/dashboard/generated/swagger/docs.go b/internal/dashboard/generated/swagger/docs.go index 21a7dd163..3d08767c3 100644 --- a/internal/dashboard/generated/swagger/docs.go +++ b/internal/dashboard/generated/swagger/docs.go @@ -347,6 +347,76 @@ const docTemplate = `{ } } }, + "/api/v1/ac/password": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Reset user's own password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AccessControl" + ], + "summary": "Reset user's own password", + "operationId": "ResetPassword", + "parameters": [ + { + "description": "reset password", + "name": "resetParam", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/param.ResetPasswordParam" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/ac.Account" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/ac/policies": { "get": { "security": [ @@ -5748,6 +5818,9 @@ const docTemplate = `{ "lastLoginAt": { "type": "string" }, + "needReset": { + "type": "boolean" + }, "nickname": { "type": "string" }, @@ -7103,6 +7176,20 @@ const docTemplate = `{ } } }, + "param.ResetPasswordParam": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "oldPassword": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "param.ResourcePoolSpec": { "type": "object", "required": [ diff --git a/internal/dashboard/generated/swagger/swagger.json b/internal/dashboard/generated/swagger/swagger.json index 786acb327..f6d8a5350 100644 --- a/internal/dashboard/generated/swagger/swagger.json +++ b/internal/dashboard/generated/swagger/swagger.json @@ -340,6 +340,76 @@ } } }, + "/api/v1/ac/password": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Reset user's own password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AccessControl" + ], + "summary": "Reset user's own password", + "operationId": "ResetPassword", + "parameters": [ + { + "description": "reset password", + "name": "resetParam", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/param.ResetPasswordParam" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/ac.Account" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/ac/policies": { "get": { "security": [ @@ -5741,6 +5811,9 @@ "lastLoginAt": { "type": "string" }, + "needReset": { + "type": "boolean" + }, "nickname": { "type": "string" }, @@ -7096,6 +7169,20 @@ } } }, + "param.ResetPasswordParam": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "oldPassword": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "param.ResourcePoolSpec": { "type": "object", "required": [ diff --git a/internal/dashboard/generated/swagger/swagger.yaml b/internal/dashboard/generated/swagger/swagger.yaml index fece51702..16491d129 100644 --- a/internal/dashboard/generated/swagger/swagger.yaml +++ b/internal/dashboard/generated/swagger/swagger.yaml @@ -6,6 +6,8 @@ definitions: type: string lastLoginAt: type: string + needReset: + type: boolean nickname: type: string roles: @@ -938,6 +940,15 @@ definitions: unlimited: type: boolean type: object + param.ResetPasswordParam: + properties: + oldPassword: + type: string + password: + type: string + required: + - password + type: object param.ResourcePoolSpec: properties: priority: @@ -2708,6 +2719,48 @@ paths: summary: get account info tags: - AccessControl + /api/v1/ac/password: + post: + consumes: + - application/json + description: Reset user's own password + operationId: ResetPassword + parameters: + - description: reset password + in: body + name: resetParam + required: true + schema: + $ref: '#/definitions/param.ResetPasswordParam' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/ac.Account' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - ApiKeyAuth: [] + summary: Reset user's own password + tags: + - AccessControl /api/v1/ac/policies: get: consumes: diff --git a/internal/dashboard/handler/ac_handler.go b/internal/dashboard/handler/ac_handler.go index c1d83890a..9ba9a0c54 100644 --- a/internal/dashboard/handler/ac_handler.go +++ b/internal/dashboard/handler/ac_handler.go @@ -15,9 +15,12 @@ package handler import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + logger "github.com/sirupsen/logrus" acbiz "github.com/oceanbase/ob-operator/internal/dashboard/business/ac" acmodel "github.com/oceanbase/ob-operator/internal/dashboard/model/ac" + "github.com/oceanbase/ob-operator/internal/dashboard/model/param" + crypto "github.com/oceanbase/ob-operator/pkg/crypto" httpErr "github.com/oceanbase/ob-operator/pkg/errors" ) @@ -42,6 +45,49 @@ func GetAccountInfo(c *gin.Context) (*acmodel.Account, error) { return acbiz.GetAccount(c, username.(string)) } +// @ID ResetPassword +// @Summary Reset user's own password +// @Description Reset user's own password +// @Tags AccessControl +// @Accept application/json +// @Produce application/json +// @Param resetParam body param.ResetPasswordParam true "reset password" +// @Success 200 object response.APIResponse{data=ac.Account} +// @Failure 400 object response.APIResponse +// @Failure 401 object response.APIResponse +// @Failure 500 object response.APIResponse +// @Router /api/v1/ac/password [POST] +// @Security ApiKeyAuth +func ResetPassword(c *gin.Context) (*acmodel.Account, error) { + username := c.GetString("username") + if username == "" { + return nil, httpErr.NewUnauthorized("unauthorized") + } + param := ¶m.ResetPasswordParam{} + if err := c.BindJSON(param); err != nil { + return nil, httpErr.NewBadRequest(err.Error()) + } + decryptedPwd, err := crypto.DecryptWithPrivateKey(param.Password) + if err != nil { + return nil, httpErr.NewBadRequest(err.Error()) + } + param.Password = decryptedPwd + + if param.OldPassword != "" { + decryptedPwd, err := crypto.DecryptWithPrivateKey(param.OldPassword) + if err != nil { + return nil, httpErr.NewBadRequest(err.Error()) + } + param.OldPassword = decryptedPwd + } + + acc, err := acbiz.ResetAccountPassword(c, username, param) + if err != nil { + return nil, err + } + return acc, nil +} + // @ID ListAllAccounts // @Summary List all accounts // @Description List all accounts @@ -55,7 +101,12 @@ func GetAccountInfo(c *gin.Context) (*acmodel.Account, error) { // @Router /api/v1/ac/accounts [GET] // @Security ApiKeyAuth func ListAccounts(c *gin.Context) ([]acmodel.Account, error) { - return acbiz.ListAccounts(c) + accounts, err := acbiz.ListAccounts(c) + if err != nil { + return nil, err + } + logger.Debugf("List accounts: %v", accounts) + return accounts, nil } // @ID CreateAccount @@ -77,6 +128,16 @@ func CreateAccount(c *gin.Context) (*acmodel.Account, error) { if err != nil { return nil, httpErr.NewBadRequest(err.Error()) } + decryptedPwd, err := crypto.DecryptWithPrivateKey(param.Password) + if err != nil { + return nil, httpErr.NewBadRequest(err.Error()) + } + param.Password = decryptedPwd + logger. + WithField("Nickname", param.Nickname). + WithField("Description", param.Description). + WithField("Roles", param.Roles). + Infof("Create account: %s", param.Username) return acbiz.CreateAccount(c, ¶m) } @@ -104,6 +165,18 @@ func PatchAccount(c *gin.Context) (*acmodel.Account, error) { if err != nil { return nil, httpErr.NewBadRequest(err.Error()) } + if param.Password != "" { + decryptedPwd, err := crypto.DecryptWithPrivateKey(param.Password) + if err != nil { + return nil, httpErr.NewBadRequest(err.Error()) + } + param.Password = decryptedPwd + } + logger. + WithField("Nickname", param.Nickname). + WithField("Description", param.Description). + WithField("Roles", param.Roles). + Infof("Patch account: %s", username) return acbiz.PatchAccount(c, username, ¶m) } @@ -130,6 +203,7 @@ func DeleteAccount(c *gin.Context) (*acmodel.Account, error) { if username == "" { return nil, httpErr.NewBadRequest("Username is required") } + logger.Infof("Delete account: %s", username) return acbiz.DeleteAccount(c, username) } @@ -146,7 +220,12 @@ func DeleteAccount(c *gin.Context) (*acmodel.Account, error) { // @Router /api/v1/ac/roles [GET] // @Security ApiKeyAuth func ListRoles(c *gin.Context) ([]*acmodel.Role, error) { - return acbiz.ListRoles(c) + roles, err := acbiz.ListRoles(c) + if err != nil { + return nil, err + } + logger.Debugf("List roles: %v", roles) + return roles, nil } // @ID CreateRole @@ -168,6 +247,12 @@ func CreateRole(c *gin.Context) (*acmodel.Role, error) { if err != nil { return nil, httpErr.NewBadRequest(err.Error()) } + logger. + WithFields(logger.Fields{ + "Description": param.Description, + "Permissions": param.Permissions, + }). + Infof("Create role: %s", param.Name) return acbiz.CreateRole(c, ¶m) } @@ -195,6 +280,12 @@ func PatchRole(c *gin.Context) (*acmodel.Role, error) { if err != nil { return nil, httpErr.NewBadRequest(err.Error()) } + logger. + WithFields(logger.Fields{ + "Description": param.Description, + "Permissions": param.Permissions, + }). + Infof("Patch role: %s", name) return acbiz.PatchRole(c, name, ¶m) } @@ -216,6 +307,7 @@ func DeleteRole(c *gin.Context) (*acmodel.Role, error) { if name == "" { return nil, httpErr.NewBadRequest("Role name is required") } + logger.Infof("Delete role: %s", name) return acbiz.DeleteRole(c, name) } diff --git a/internal/dashboard/handler/user_handler.go b/internal/dashboard/handler/user_handler.go index 20dc9f161..fd807cc7e 100644 --- a/internal/dashboard/handler/user_handler.go +++ b/internal/dashboard/handler/user_handler.go @@ -17,6 +17,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + logger "github.com/sirupsen/logrus" acbiz "github.com/oceanbase/ob-operator/internal/dashboard/business/ac" "github.com/oceanbase/ob-operator/internal/dashboard/business/auth" @@ -60,6 +61,7 @@ func Login(c *gin.Context) (*acmodel.Account, error) { return nil, httpErr.NewInternal(err.Error()) } store.GetCache().Store(loginParams.Username, struct{}{}) + logger.Infof("User logs in, username: %s", loginParams.Username) return acc, nil } @@ -84,6 +86,7 @@ func Logout(c *gin.Context) (string, error) { } if usernameEntry != nil { store.GetCache().Delete(usernameEntry.(string)) + logger.Infof("User logs out, username: %s", usernameEntry) } return "logout successfully", nil } @@ -102,6 +105,8 @@ func Authz(c *gin.Context) (*auth.AuthUser, error) { if !ok { return nil, httpErr.NewUnauthorized("invalid token") } - + logger. + WithField("token", urlParam.Token.String()). + Infof("User %s is authorized", authUser.Username) return authUser, nil } diff --git a/internal/dashboard/model/ac/internal.go b/internal/dashboard/model/ac/internal.go new file mode 100644 index 000000000..561122362 --- /dev/null +++ b/internal/dashboard/model/ac/internal.go @@ -0,0 +1,60 @@ +/* +Copyright (c) 2024 OceanBase +ob-operator is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. +*/ + +package ac + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + accountLineSeparator = " <|:SEP:|> " +) + +type AccountCreds struct { + EncryptedPassword string + Nickname string + LastLoginAtUnix int64 + Description string +} + +func NewAccountCreds(infoLine string) (*AccountCreds, error) { + parts := strings.SplitN(infoLine, accountLineSeparator, 4) + if len(parts) != 4 { + return nil, errors.New("User credentials file is corrupted: invalid format") + } + ts, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return nil, errors.New("User credentials file is corrupted: last login time is not a valid timestamp") + } + return &AccountCreds{ + EncryptedPassword: parts[0], + Nickname: parts[1], + LastLoginAtUnix: ts, + Description: strings.TrimSpace(parts[3]), + }, nil +} + +type UpdateAccountCreds struct { + AccountCreds + Username string + Delete bool +} + +// key value format -> admin: pwd nickname lastLogin description +func (u *AccountCreds) ToLine() string { + lastLoginAtStr := fmt.Sprintf("%d", u.LastLoginAtUnix) + return strings.Join([]string{u.EncryptedPassword, u.Nickname, lastLoginAtStr, u.Description}, accountLineSeparator) +} diff --git a/internal/dashboard/model/ac/response.go b/internal/dashboard/model/ac/response.go index 586814cc6..76e58dfa8 100644 --- a/internal/dashboard/model/ac/response.go +++ b/internal/dashboard/model/ac/response.go @@ -20,6 +20,7 @@ type Account struct { Description string `json:"description"` Roles []Role `json:"roles" binding:"required"` LastLoginAt *time.Time `json:"lastLoginAt"` + NeedReset bool `json:"needReset"` } type Role struct { diff --git a/internal/dashboard/model/param/login_param.go b/internal/dashboard/model/param/login_param.go index ae822fdaf..c2b167d07 100644 --- a/internal/dashboard/model/param/login_param.go +++ b/internal/dashboard/model/param/login_param.go @@ -16,3 +16,8 @@ type LoginParam struct { Username string `json:"username"` Password string `json:"password"` } + +type ResetPasswordParam struct { + Password string `json:"password" binding:"required"` + OldPassword string `json:"oldPassword"` +} diff --git a/internal/dashboard/router/v1/ac_router.go b/internal/dashboard/router/v1/ac_router.go index 544c0b1c7..776f34db4 100644 --- a/internal/dashboard/router/v1/ac_router.go +++ b/internal/dashboard/router/v1/ac_router.go @@ -31,5 +31,6 @@ func InitAccessControlRoutes(g *gin.RouterGroup) { g.DELETE("/ac/roles/:name", h.Wrap(h.DeleteRole, acbiz.PathGuard("ac", "*", "write"))) g.GET("/ac/info", h.Wrap(h.GetAccountInfo)) - g.GET("/ac/policies", h.Wrap(h.ListAllPolicies, acbiz.PathGuard("ac", "*", "read"))) + g.GET("/ac/policies", h.Wrap(h.ListAllPolicies)) + g.POST("/ac/password", h.Wrap(h.ResetPassword)) } diff --git a/internal/dashboard/router/v1/info_router.go b/internal/dashboard/router/v1/info_router.go index 135f78375..5292de38b 100644 --- a/internal/dashboard/router/v1/info_router.go +++ b/internal/dashboard/router/v1/info_router.go @@ -21,6 +21,6 @@ import ( func InitInfoRoutes(g *gin.RouterGroup) { g.GET("/info", h.Wrap(h.GetProcessInfo)) - g.GET("/statistics", h.Wrap(h.GetStatistics, acbiz.PathGuard("system", "*", "read"))) + g.GET("/statistics", h.Wrap(h.GetStatistics)) g.PATCH("/info", h.Wrap(h.ConfigureInfo, acbiz.PathGuard("system", "*", "write"))) } diff --git a/internal/dashboard/router/v1/obtenant_router.go b/internal/dashboard/router/v1/obtenant_router.go index 56187e7ea..70ee8dbf3 100644 --- a/internal/dashboard/router/v1/obtenant_router.go +++ b/internal/dashboard/router/v1/obtenant_router.go @@ -31,7 +31,7 @@ func InitOBTenantRoutes(g *gin.RouterGroup) { g.POST("/obtenants/:namespace/:name/logreplay", h.Wrap(h.ReplayStandbyLog, oceanbase.TenantGuard(":namespace", ":name", "write"))) g.POST("/obtenants/:namespace/:name/version", h.Wrap(h.UpgradeTenantVersion, oceanbase.TenantGuard(":namespace", ":name", "write"))) g.POST("/obtenants/:namespace/:name/role", h.Wrap(h.ChangeTenantRole, oceanbase.TenantGuard(":namespace", ":name", "write"))) - g.GET("/obtenants/:namespace/:name/backupPolicy", h.Wrap(h.GetBackupPolicy, oceanbase.TenantGuard(":namespace", ":name", "write"))) + g.GET("/obtenants/:namespace/:name/backupPolicy", h.Wrap(h.GetBackupPolicy, oceanbase.TenantGuard(":namespace", ":name", "read"))) g.PUT("/obtenants/:namespace/:name/backupPolicy", h.Wrap(h.CreateBackupPolicy, oceanbase.TenantGuard(":namespace", ":name", "write"))) g.PATCH("/obtenants/:namespace/:name/backupPolicy", h.Wrap(h.UpdateBackupPolicy, oceanbase.TenantGuard(":namespace", ":name", "write"))) g.DELETE("/obtenants/:namespace/:name/backupPolicy", h.Wrap(h.DeleteBackupPolicy, oceanbase.TenantGuard(":namespace", ":name", "write")))