diff options
author | Jack Hay <jack@allspice.io> | 2023-06-04 14:57:16 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-04 20:57:16 +0200 |
commit | 18de83b2a3fc120922096b7348d6375094ae1532 (patch) | |
tree | a9724bcb6f00a040e5a16970ce56931cd1aa3d51 /web_src/js/components/ScopedAccessTokenSelector.vue | |
parent | 520eb57d7642a5fca3df319e5b5d1c7c9018087c (diff) | |
download | gitea-18de83b2a3fc120922096b7348d6375094ae1532.tar.gz gitea-18de83b2a3fc120922096b7348d6375094ae1532.zip |
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Diffstat (limited to 'web_src/js/components/ScopedAccessTokenSelector.vue')
-rw-r--r-- | web_src/js/components/ScopedAccessTokenSelector.vue | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue new file mode 100644 index 0000000000..769f3262b4 --- /dev/null +++ b/web_src/js/components/ScopedAccessTokenSelector.vue @@ -0,0 +1,138 @@ +<template> + <div class="scoped-access-token-category"> + <div class="field gt-pl-2"> + <label class="checkbox-label"> + <input + ref="category" + v-model="categorySelected" + class="scope-checkbox scoped-access-token-input" + type="checkbox" + name="scope" + :value="'write:' + category" + @input="onCategoryInput" + > + {{ category }} + </label> + </div> + <div class="field gt-pl-4"> + <div class="inline field"> + <label class="checkbox-label"> + <input + ref="read" + v-model="readSelected" + :disabled="disableIndividual || writeSelected" + class="scope-checkbox scoped-access-token-input" + type="checkbox" + name="scope" + :value="'read:' + category" + @input="onIndividualInput" + > + read:{{ category }} + </label> + </div> + <div class="inline field"> + <label class="checkbox-label"> + <input + ref="write" + v-model="writeSelected" + :disabled="disableIndividual" + class="scope-checkbox scoped-access-token-input" + type="checkbox" + name="scope" + :value="'write:' + category" + @input="onIndividualInput" + > + write:{{ category }} + </label> + </div> + </div> + </div> +</template> + +<script> +import {createApp} from 'vue'; +import {showElem} from '../utils/dom.js'; + +const sfc = { + props: { + category: { + type: String, + required: true, + }, + }, + + data: () => ({ + categorySelected: false, + disableIndividual: false, + readSelected: false, + writeSelected: false, + }), + + methods: { + /** + * When entire category is toggled + * @param {Event} e + */ + onCategoryInput(e) { + e.preventDefault(); + this.disableIndividual = this.$refs.category.checked; + this.writeSelected = this.$refs.category.checked; + this.readSelected = this.$refs.category.checked; + }, + + /** + * When an individual level of category is toggled + * @param {Event} e + */ + onIndividualInput(e) { + e.preventDefault(); + if (this.$refs.write.checked) { + this.readSelected = true; + } + this.categorySelected = this.$refs.write.checked; + }, + } +}; + +export default sfc; + +/** + * Initialize category toggle sections + */ +export function initScopedAccessTokenCategories() { + for (const el of document.getElementsByTagName('scoped-access-token-category')) { + const category = el.getAttribute('category'); + createApp(sfc, { + category, + }).mount(el); + } + + document.getElementById('scoped-access-submit')?.addEventListener('click', (e) => { + e.preventDefault(); + // check that at least one scope has been selected + for (const el of document.getElementsByClassName('scoped-access-token-input')) { + if (el.checked) { + document.getElementById('scoped-access-form').submit(); + } + } + // no scopes selected, show validation error + showElem(document.getElementById('scoped-access-warning')); + }); +} + +</script> + +<style scoped> +.scoped-access-token-category { + padding-top: 10px; + padding-bottom: 10px; +} + +.checkbox-label { + cursor: pointer; +} + +.scope-checkbox { + margin: 4px 5px 0 0; +} +</style> |