diff --git a/api/swagger/docs.go b/api/swagger/docs.go index 5d747e14..67806dea 100644 --- a/api/swagger/docs.go +++ b/api/swagger/docs.go @@ -1854,6 +1854,46 @@ const docTemplate = `{ } } }, + "/organizations/{organizationId}/my-profile/next-password-change": { + "put": { + "security": [ + { + "JWT": [] + } + ], + "description": "Update user's password expired date to current date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "My-profile" + ], + "summary": "Update user's password expired date to current date", + "parameters": [ + { + "type": "string", + "description": "organizationId", + "name": "organizationId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpErrors.RestError" + } + } + } + } + }, "/organizations/{organizationId}/my-profile/password": { "put": { "security": [ @@ -2503,6 +2543,47 @@ const docTemplate = `{ } } }, + "/organizations/{organizationId}/users/{accountId}/reset-password": { + "put": { + "security": [ + { + "JWT": [] + } + ], + "description": "Reset user's password as temporary password by admin and send email to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Reset user's password as temporary password by admin", + "parameters": [ + { + "type": "string", + "description": "organizationId", + "name": "organizationId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "accountId", + "name": "accountId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/stack-templates": { "get": { "security": [ diff --git a/api/swagger/swagger.json b/api/swagger/swagger.json index 5e1e7519..d726ac4d 100644 --- a/api/swagger/swagger.json +++ b/api/swagger/swagger.json @@ -1847,6 +1847,46 @@ } } }, + "/organizations/{organizationId}/my-profile/next-password-change": { + "put": { + "security": [ + { + "JWT": [] + } + ], + "description": "Update user's password expired date to current date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "My-profile" + ], + "summary": "Update user's password expired date to current date", + "parameters": [ + { + "type": "string", + "description": "organizationId", + "name": "organizationId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpErrors.RestError" + } + } + } + } + }, "/organizations/{organizationId}/my-profile/password": { "put": { "security": [ @@ -2496,6 +2536,47 @@ } } }, + "/organizations/{organizationId}/users/{accountId}/reset-password": { + "put": { + "security": [ + { + "JWT": [] + } + ], + "description": "Reset user's password as temporary password by admin and send email to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Reset user's password as temporary password by admin", + "parameters": [ + { + "type": "string", + "description": "organizationId", + "name": "organizationId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "accountId", + "name": "accountId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/stack-templates": { "get": { "security": [ diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index a41bca63..809dba2d 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2693,6 +2693,31 @@ paths: summary: Update my profile detail tags: - My-profile + /organizations/{organizationId}/my-profile/next-password-change: + put: + consumes: + - application/json + description: Update user's password expired date to current date + parameters: + - description: organizationId + in: path + name: organizationId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpErrors.RestError' + security: + - JWT: [] + summary: Update user's password expired date to current date + tags: + - My-profile /organizations/{organizationId}/my-profile/password: put: consumes: @@ -3054,6 +3079,33 @@ paths: summary: Update user tags: - Users + /organizations/{organizationId}/users/{accountId}/reset-password: + put: + consumes: + - application/json + description: Reset user's password as temporary password by admin and send email + to user + parameters: + - description: organizationId + in: path + name: organizationId + required: true + type: string + - description: accountId + in: path + name: accountId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + security: + - JWT: [] + summary: Reset user's password as temporary password by admin + tags: + - Users /organizations/{organizationId}/users/accountId/{accountId}/existence: get: description: return true when accountId exists diff --git a/internal/aws/ses/ses.go b/internal/aws/ses/ses.go index 488d92e1..9be75772 100644 --- a/internal/aws/ses/ses.go +++ b/internal/aws/ses/ses.go @@ -84,8 +84,10 @@ func SendEmailForVerityIdentity(client *awsSes.Client, targetEmailAddress string func SendEmailForTemporaryPassword(client *awsSes.Client, targetEmailAddress string, randomPassword string) error { subject := "[TKS] 비밀번호 초기화" - body := "임시 비밀번호가 발급되었습니다.\n\n" + "임시 비밀번호는 [" + randomPassword + "]이며\n" + - "로그인 후 비밀번호를 변경하여 사용하십시요.\n\n" + "TKS를 이용해 주셔서 감사합니다.\nTKS Team 드림" + body := "임시 비밀번호가 발급되었습니다.\n" + + "로그인 후 비밀번호를 변경하여 사용하십시오.\n\n" + + "임시 비밀번호: " + randomPassword + "\n\n" + + "TKS를 이용해 주셔서 감사합니다.\nTKS Team 드림" input := &awsSes.SendEmailInput{ Destination: &types.Destination{ diff --git a/internal/delivery/http/user.go b/internal/delivery/http/user.go index b3ab2316..d08813bc 100644 --- a/internal/delivery/http/user.go +++ b/internal/delivery/http/user.go @@ -18,10 +18,12 @@ type IUserHandler interface { Get(w http.ResponseWriter, r *http.Request) Delete(w http.ResponseWriter, r *http.Request) Update(w http.ResponseWriter, r *http.Request) + ResetPassword(w http.ResponseWriter, r *http.Request) GetMyProfile(w http.ResponseWriter, r *http.Request) UpdateMyProfile(w http.ResponseWriter, r *http.Request) UpdateMyPassword(w http.ResponseWriter, r *http.Request) + RenewPasswordExpiredDate(w http.ResponseWriter, r *http.Request) DeleteMyProfile(w http.ResponseWriter, r *http.Request) CheckId(w http.ResponseWriter, r *http.Request) @@ -294,6 +296,39 @@ func (u UserHandler) Update(w http.ResponseWriter, r *http.Request) { ResponseJSON(w, http.StatusOK, out) } +// ResetPassword godoc +// @Tags Users +// @Summary Reset user's password as temporary password by admin +// @Description Reset user's password as temporary password by admin and send email to user +// @Accept json +// @Produce json +// @Param organizationId path string true "organizationId" +// @Param accountId path string true "accountId" +// @Success 200 +// @Router /organizations/{organizationId}/users/{accountId}/reset-password [put] +// @Security JWT +func (u UserHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + accountId, ok := vars["accountId"] + if !ok { + ErrorJSON(w, httpErrors.NewBadRequestError(fmt.Errorf("accountId not found in path"))) + return + } + organizationId, ok := vars["organizationId"] + if !ok { + ErrorJSON(w, httpErrors.NewBadRequestError(fmt.Errorf("organizationId not found in path"))) + return + } + + err := u.usecase.ResetPasswordByAccountId(accountId, organizationId) + if err != nil { + ErrorJSON(w, err) + return + } + + ResponseJSON(w, http.StatusOK, nil) +} + // GetMyProfile godoc // @Tags My-profile // @Summary Get my profile detail @@ -436,6 +471,33 @@ func (u UserHandler) UpdateMyPassword(w http.ResponseWriter, r *http.Request) { ResponseJSON(w, http.StatusOK, nil) } +// RenewPasswordExpiredDate godoc +// @Tags My-profile +// @Summary Update user's password expired date to current date +// @Description Update user's password expired date to current date +// @Accept json +// @Produce json +// @Param organizationId path string true "organizationId" +// @Success 200 +// @Failure 400 {object} httpErrors.RestError +// @Router /organizations/{organizationId}/my-profile/next-password-change [put] +// @Security JWT +func (u UserHandler) RenewPasswordExpiredDate(w http.ResponseWriter, r *http.Request) { + requestUserInfo, ok := request.UserFrom(r.Context()) + if !ok { + ErrorJSON(w, httpErrors.NewInternalServerError(fmt.Errorf("user not found in request"))) + return + } + + err := u.usecase.RenewalPasswordExpiredTime(r.Context(), requestUserInfo.GetUserId()) + if err != nil { + ErrorJSON(w, err) + return + } + + ResponseJSON(w, http.StatusOK, nil) +} + // DeleteMyProfile godoc // @Tags My-profile // @Summary Delete myProfile diff --git a/internal/keycloak/config.go b/internal/keycloak/config.go index 9f8e5786..cb87c975 100644 --- a/internal/keycloak/config.go +++ b/internal/keycloak/config.go @@ -13,6 +13,6 @@ const ( DefaultClientSecret = "secret" AdminCliClientID = "admin-cli" accessTokenLifespan = 60 * 60 * 24 // 1 day - ssoSessionIdleTimeout = 60 * 60 * 8 // 2 hours + ssoSessionIdleTimeout = 60 * 60 * 8 // 8 hours ssoSessionMaxLifespan = 60 * 60 * 24 // 1 day ) diff --git a/internal/middleware/auth/authorizer/password.go b/internal/middleware/auth/authorizer/password.go index b9e9992d..f45678ba 100644 --- a/internal/middleware/auth/authorizer/password.go +++ b/internal/middleware/auth/authorizer/password.go @@ -30,8 +30,11 @@ func PasswordFilter(handler http.Handler, repo repository.Repository) http.Handl return } if helper.IsDurationExpired(storedUser.PasswordUpdatedAt, internal.PasswordExpiredDuration) { - allowedUrl := internal.API_PREFIX + internal.API_VERSION + "/organizations/" + requestUserInfo.GetOrganizationId() + "/my-profile" + "/password" - if !(r.URL.Path == allowedUrl && r.Method == http.MethodPut) { + allowedUrl := [2]string{ + internal.API_PREFIX + internal.API_VERSION + "/organizations/" + requestUserInfo.GetOrganizationId() + "/my-profile" + "/password", + internal.API_PREFIX + internal.API_VERSION + "/organizations/" + requestUserInfo.GetOrganizationId() + "/my-profile" + "/next-password-change", + } + if !(urlContains(allowedUrl, r.URL.Path) && r.Method == http.MethodPut) { internalHttp.ErrorJSON(w, httpErrors.NewForbiddenError(fmt.Errorf("password expired"))) return } @@ -39,3 +42,12 @@ func PasswordFilter(handler http.Handler, repo repository.Repository) http.Handl handler.ServeHTTP(w, r) }) } + +func urlContains(urls [2]string, url string) bool { + for _, u := range urls { + if u == url { + return true + } + } + return false +} diff --git a/internal/middleware/auth/authorizer/rbac.go b/internal/middleware/auth/authorizer/rbac.go index 0efba7cc..f3cf9521 100644 --- a/internal/middleware/auth/authorizer/rbac.go +++ b/internal/middleware/auth/authorizer/rbac.go @@ -22,6 +22,12 @@ func RBACFilter(handler http.Handler, repo repository.Repository) http.Handler { } role := requestUserInfo.GetRoleProjectMapping()[requestUserInfo.GetOrganizationId()] + // TODO: 추후 tks-admin role 수정 필요 + if role == "tks-admin" { + handler.ServeHTTP(w, r) + return + } + vars := mux.Vars(r) // Organization Filter if role == "admin" || role == "user" { diff --git a/internal/route/route.go b/internal/route/route.go index 2fa7fbd1..ab92ec42 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -83,11 +83,13 @@ func SetupRouter(db *gorm.DB, argoClient argowf.ArgoClient, asset http.Handler, r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users", authMiddleware.Handle(http.HandlerFunc(userHandler.List))).Methods(http.MethodGet) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users/{accountId}", authMiddleware.Handle(http.HandlerFunc(userHandler.Get))).Methods(http.MethodGet) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users/{accountId}", authMiddleware.Handle(http.HandlerFunc(userHandler.Update))).Methods(http.MethodPut) + r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users/{accountId}/reset-password", authMiddleware.Handle(http.HandlerFunc(userHandler.ResetPassword))).Methods(http.MethodPut) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users/{accountId}", authMiddleware.Handle(http.HandlerFunc(userHandler.Delete))).Methods(http.MethodDelete) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/my-profile", authMiddleware.Handle(http.HandlerFunc(userHandler.GetMyProfile))).Methods(http.MethodGet) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/my-profile", authMiddleware.Handle(http.HandlerFunc(userHandler.UpdateMyProfile))).Methods(http.MethodPut) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/my-profile/password", authMiddleware.Handle(http.HandlerFunc(userHandler.UpdateMyPassword))).Methods(http.MethodPut) + r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/my-profile/next-password-change", authMiddleware.Handle(http.HandlerFunc(userHandler.RenewPasswordExpiredDate))).Methods(http.MethodPut) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/my-profile", authMiddleware.Handle(http.HandlerFunc(userHandler.DeleteMyProfile))).Methods(http.MethodDelete) r.Handle(API_PREFIX+API_VERSION+"/organizations/{organizationId}/users/accountId/{accountId}/existence", authMiddleware.Handle(http.HandlerFunc(userHandler.CheckId))).Methods(http.MethodGet) diff --git a/internal/usecase/user.go b/internal/usecase/user.go index c788964a..3c5a2771 100644 --- a/internal/usecase/user.go +++ b/internal/usecase/user.go @@ -3,6 +3,7 @@ package usecase import ( "context" "fmt" + "github.com/openinfradev/tks-api/internal/aws/ses" "github.com/openinfradev/tks-api/internal/middleware/auth/request" "net/http" @@ -25,12 +26,16 @@ type IUserUsecase interface { List(ctx context.Context, organizationId string) (*[]domain.User, error) Get(userId uuid.UUID) (*domain.User, error) Update(ctx context.Context, userId uuid.UUID, user *domain.User) (*domain.User, error) + ResetPassword(userId uuid.UUID) error + ResetPasswordByAccountId(accountId string, organizationId string) error Delete(userId uuid.UUID, organizationId string) error GetByAccountId(ctx context.Context, accountId string, organizationId string) (*domain.User, error) GetByEmail(ctx context.Context, email string, organizationId string) (*domain.User, error) UpdateByAccountId(ctx context.Context, accountId string, user *domain.User) (*domain.User, error) UpdatePasswordByAccountId(ctx context.Context, accountId string, originPassword string, newPassword string, organizationId string) error + RenewalPasswordExpiredTime(ctx context.Context, userId uuid.UUID) error + RenewalPasswordExpiredTimeByAccountId(ctx context.Context, accountId string, organizationId string) error DeleteByAccountId(ctx context.Context, accountId string, organizationId string) error ValidateAccount(userId uuid.UUID, password string, organizationId string) error ValidateAccountByAccountId(accountId string, password string, organizationId string) error @@ -43,6 +48,95 @@ type UserUsecase struct { kc keycloak.IKeycloak } +func (u *UserUsecase) RenewalPasswordExpiredTime(ctx context.Context, userId uuid.UUID) error { + user, err := u.repo.GetByUuid(userId) + if err != nil { + if _, status := httpErrors.ErrorResponse(err); status != http.StatusNotFound { + return httpErrors.NewBadRequestError(fmt.Errorf("user not found")) + } + return httpErrors.NewInternalServerError(err) + } + + err = u.repo.UpdatePassword(userId, user.Organization.ID, user.Password, false) + if err != nil { + log.Errorf("failed to update password expired time: %v", err) + return httpErrors.NewInternalServerError(err) + } + + return nil +} + +func (u *UserUsecase) RenewalPasswordExpiredTimeByAccountId(ctx context.Context, accountId string, organizationId string) error { + user, err := u.repo.Get(accountId, organizationId) + if err != nil { + if _, status := httpErrors.ErrorResponse(err); status != http.StatusNotFound { + return httpErrors.NewBadRequestError(fmt.Errorf("user not found")) + } + return httpErrors.NewInternalServerError(err) + } + userId, err := uuid.Parse(user.ID) + if err != nil { + return httpErrors.NewInternalServerError(err) + } + return u.RenewalPasswordExpiredTime(ctx, userId) +} + +func (u *UserUsecase) ResetPassword(userId uuid.UUID) error { + user, err := u.repo.GetByUuid(userId) + if err != nil { + if _, status := httpErrors.ErrorResponse(err); status == http.StatusNotFound { + return httpErrors.NewBadRequestError(fmt.Errorf("user not found")) + } + } + userInKeycloak, err := u.kc.GetUser(user.Organization.ID, user.AccountId) + if err != nil { + if _, status := httpErrors.ErrorResponse(err); status == http.StatusNotFound { + return httpErrors.NewBadRequestError(fmt.Errorf("user not found")) + } + return httpErrors.NewInternalServerError(err) + } + + randomPassword := helper.GenerateRandomString(passwordLength) + userInKeycloak.Credentials = &[]gocloak.CredentialRepresentation{ + { + Type: gocloak.StringP("password"), + Value: gocloak.StringP(randomPassword), + Temporary: gocloak.BoolP(false), + }, + } + if err = u.kc.UpdateUser(user.Organization.ID, userInKeycloak); err != nil { + return httpErrors.NewInternalServerError(err) + } + + if user.Password, err = helper.HashPassword(randomPassword); err != nil { + return httpErrors.NewInternalServerError(err) + } + if err = u.repo.UpdatePassword(userId, user.Organization.ID, user.Password, true); err != nil { + return httpErrors.NewInternalServerError(err) + } + + if err = ses.SendEmailForTemporaryPassword(ses.Client, user.Email, randomPassword); err != nil { + return httpErrors.NewInternalServerError(err) + } + + return nil +} + +func (u *UserUsecase) ResetPasswordByAccountId(accountId string, organizationId string) error { + user, err := u.repo.Get(accountId, organizationId) + if err != nil { + if _, status := httpErrors.ErrorResponse(err); status == http.StatusNotFound { + return httpErrors.NewBadRequestError(fmt.Errorf("user not found")) + } + return httpErrors.NewInternalServerError(err) + } + userId, err := uuid.Parse(user.ID) + if err != nil { + return httpErrors.NewInternalServerError(err) + } + return u.ResetPassword(userId) +} + func (u *UserUsecase) ValidateAccount(userId uuid.UUID, password string, organizationId string) error { user, err := u.repo.GetByUuid(userId) if err != nil {