mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 03:18:24 +00:00 
			
		
		
		
	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>
			
			
This commit is contained in:
		
							
								
								
									
										138
									
								
								web_src/js/components/ScopedAccessTokenSelector.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								web_src/js/components/ScopedAccessTokenSelector.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
| @@ -2,6 +2,7 @@ | ||||
| import './bootstrap.js'; | ||||
|  | ||||
| import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue'; | ||||
| import {initScopedAccessTokenCategories} from './components/ScopedAccessTokenSelector.vue'; | ||||
| import {initDashboardRepoList} from './components/DashboardRepoList.vue'; | ||||
|  | ||||
| import {initGlobalCopyToClipboardListener} from './features/clipboard.js'; | ||||
| @@ -177,4 +178,5 @@ onDomReady(() => { | ||||
|   initUserSettings(); | ||||
|   initRepoDiffView(); | ||||
|   initPdfViewer(); | ||||
|   initScopedAccessTokenCategories(); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user