diff --git a/CHANGELOG.md b/CHANGELOG.md index a02f476606..5a19431889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) +- Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697) - Wiki full-text search: case-insensitive search across all wiki page titles and content (#550) - Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550) - Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692) diff --git a/modules/structs/user_app.go b/modules/structs/user_app.go index 76add1c635..b844168326 100644 --- a/modules/structs/user_app.go +++ b/modules/structs/user_app.go @@ -40,6 +40,16 @@ type CreateAccessTokenOption struct { Scopes []string `json:"scopes"` } +// EditAccessTokenOption options when editing access token scopes +// swagger:model EditAccessTokenOption +type EditAccessTokenOption struct { + // The new name for the token (optional) + Name string `json:"name"` + // The new scopes for the token + // example: ["read:repository", "write:issue"] + Scopes []string `json:"scopes"` +} + // CreateOAuth2ApplicationOptions holds options to create an oauth2 application type CreateOAuth2ApplicationOptions struct { // The name of the OAuth2 application diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 97aa159461..30033105a2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -855,6 +855,8 @@ "settings.access_token_deletion_confirm_action": "Delete", "settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?", "settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.", + "settings.edit_token_scopes": "Edit Token Scopes", + "settings.update_token_success": "Token scopes have been updated successfully.", "settings.repo_and_org_access": "Repository and Organization Access", "settings.permissions_public_only": "Public only", "settings.permissions_access_all": "All (public, private, and limited)", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c913ef78f9..5433f11f25 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1007,7 +1007,9 @@ func Routes() *web.Router { m.Group("/tokens", func() { m.Combo("").Get(user.ListAccessTokens). Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken) - m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken) + m.Combo("/{id}"). + Patch(bind(api.EditAccessTokenOption{}), reqToken(), user.UpdateAccessToken). + Delete(reqToken(), user.DeleteAccessToken) }, reqSelfOrAdmin(), reqBasicOrRevProxyAuth()) m.Get("/activities/feeds", user.ListUserActivityFeeds) diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 26a2bde340..cf23df4f9a 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -209,6 +209,106 @@ func DeleteAccessToken(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// UpdateAccessToken update access token scopes +func UpdateAccessToken(ctx *context.APIContext) { + // swagger:operation PATCH /users/{username}/tokens/{id} user userUpdateAccessToken + // --- + // summary: Update an access token's scopes + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user whose token is to be updated + // type: string + // required: true + // - name: id + // in: path + // description: id of the token to update + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAccessTokenOption" + // responses: + // "200": + // "$ref": "#/responses/AccessToken" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + tokenID, _ := strconv.ParseInt(ctx.PathParam("id"), 0, 64) + if tokenID == 0 { + ctx.APIErrorNotFound() + return + } + + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{ + UserID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + var token *auth_model.AccessToken + for _, t := range tokens { + if t.ID == tokenID { + token = t + break + } + } + if token == nil { + ctx.APIErrorNotFound() + return + } + + form := web.GetForm(ctx).(*api.EditAccessTokenOption) + + if form.Name == "" && len(form.Scopes) == 0 { + ctx.APIError(http.StatusBadRequest, "must provide name or scopes to update") + return + } + + if form.Name != "" { + token.Name = form.Name + } + + if len(form.Scopes) > 0 { + scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize() + if err != nil { + ctx.APIError(http.StatusBadRequest, fmt.Errorf("invalid access token scope: %w", err)) + return + } + if scope == "" { + ctx.APIError(http.StatusBadRequest, "access token must have a scope") + return + } + token.Scope = scope + } + + if err := auth_model.UpdateAccessToken(ctx, token); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, &api.AccessToken{ + ID: token.ID, + Name: token.Name, + TokenLastEight: token.TokenLastEight, + Scopes: token.Scope.StringSlice(), + Created: token.CreatedUnix.AsTime(), + Updated: token.UpdatedUnix.AsTime(), + }) +} + // CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user func CreateOauth2Application(ctx *context.APIContext) { // swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 5bde0fff25..09b5a8b4b4 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -90,6 +90,59 @@ func ApplicationsPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/applications") } +// EditApplication response for editing user access token scopes +func EditApplication(ctx *context.Context) { + tokenID := ctx.FormInt64("id") + + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + + var token *auth_model.AccessToken + for _, t := range tokens { + if t.ID == tokenID { + token = t + break + } + } + if token == nil { + ctx.Flash.Error("Token not found") + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") + return + } + + _ = ctx.Req.ParseForm() + var scopeNames []string + const accessTokenScopePrefix = "scope-" + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, accessTokenScopePrefix) { + scopeNames = append(scopeNames, v...) + } + } + + scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize() + if err != nil { + ctx.ServerError("GetScope", err) + return + } + if !scope.HasPermissionScope() { + ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") + return + } + + token.Scope = scope + if err := auth_model.UpdateAccessToken(ctx, token); err != nil { + ctx.ServerError("UpdateAccessToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.update_token_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") +} + // DeleteApplication response for delete user access token func DeleteApplication(ctx *context.Context) { if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index 2242ab275b..72660737f4 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -680,6 +680,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // access token applications m.Combo("").Get(user_setting.Applications). Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost) + m.Post("/edit", user_setting.EditApplication) m.Post("/delete", user_setting.DeleteApplication) }) diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index 7c558296b8..eef6ce1a70 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -40,6 +40,10 @@