diff --git a/composer.json b/composer.json index 391fb7d9f..f6d2417ca 100644 --- a/composer.json +++ b/composer.json @@ -84,8 +84,8 @@ "flarum/testing": "self.version" }, "require": { - "ext-json": "*", "php": ">=7.3", + "ext-json": "*", "components/font-awesome": "^5.14.0", "composer/composer": "^2.0", "dflydev/fig-cookies": "^3.0.0", @@ -110,6 +110,7 @@ "illuminate/validation": "^8.0", "illuminate/view": "^8.0", "intervention/image": "2.5.* || ^2.6.1", + "jenssegers/agent": "^2.6", "laminas/laminas-diactoros": "^2.4.1", "laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0", "laminas/laminas-stratigility": "^3.2.2", diff --git a/framework/core/composer.json b/framework/core/composer.json index dec3ee42b..208bad0d4 100644 --- a/framework/core/composer.json +++ b/framework/core/composer.json @@ -60,6 +60,7 @@ "illuminate/validation": "^8.0", "illuminate/view": "^8.0", "intervention/image": "2.5.* || ^2.6.1", + "jenssegers/agent": "^2.6", "laminas/laminas-diactoros": "^2.4.1", "laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0", "laminas/laminas-stratigility": "^3.2.2", diff --git a/framework/core/js/package.json b/framework/core/js/package.json index fedc66686..efeb58c29 100644 --- a/framework/core/js/package.json +++ b/framework/core/js/package.json @@ -29,6 +29,7 @@ "@types/mithril": "^2.0.8", "@types/punycode": "^2.1.0", "@types/textarea-caret": "^3.0.1", + "@types/ua-parser-js": "^0.7.36", "bundlewatch": "^0.3.2", "cross-env": "^7.0.3", "expose-loader": "^3.1.0", diff --git a/framework/core/js/src/admin/components/PermissionGrid.tsx b/framework/core/js/src/admin/components/PermissionGrid.tsx index 37741ff29..407552cd2 100644 --- a/framework/core/js/src/admin/components/PermissionGrid.tsx +++ b/framework/core/js/src/admin/components/PermissionGrid.tsx @@ -236,6 +236,16 @@ export default class PermissionGrid extends Component { + view(vnode: Mithril.Vnode): Mithril.Children { + return ( +
+
+ {app.translator.trans('core.lib.data_segment.label', { + label: this.attrs.label, + })} +
+
{this.attrs.value}
+
+ ); + } +} diff --git a/framework/core/js/src/common/models/AccessToken.ts b/framework/core/js/src/common/models/AccessToken.ts new file mode 100644 index 000000000..5f98f1c0c --- /dev/null +++ b/framework/core/js/src/common/models/AccessToken.ts @@ -0,0 +1,34 @@ +import Model from '../Model'; + +export default class AccessToken extends Model { + token() { + return Model.attribute('token').call(this); + } + userId() { + return Model.attribute('userId').call(this); + } + title() { + return Model.attribute('title').call(this); + } + type() { + return Model.attribute('type').call(this); + } + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + lastActivityAt() { + return Model.attribute('lastActivityAt', Model.transformDate).call(this); + } + lastIpAddress() { + return Model.attribute('lastIpAddress').call(this); + } + device() { + return Model.attribute('device').call(this); + } + isCurrent() { + return Model.attribute('isCurrent').call(this); + } + isSessionToken() { + return Model.attribute('isSessionToken').call(this); + } +} diff --git a/framework/core/js/src/common/utils/string.ts b/framework/core/js/src/common/utils/string.ts index 84ca81ff7..e11c84fd1 100644 --- a/framework/core/js/src/common/utils/string.ts +++ b/framework/core/js/src/common/utils/string.ts @@ -72,3 +72,10 @@ getPlainContent.removeSelectors = ['blockquote', 'script']; export function ucfirst(string: string): string { return string.substr(0, 1).toUpperCase() + string.substr(1); } + +/** + * Transform a camel case string to snake case. + */ +export function camelCaseToSnakeCase(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} diff --git a/framework/core/js/src/forum/components/AccessTokensList.tsx b/framework/core/js/src/forum/components/AccessTokensList.tsx new file mode 100644 index 000000000..4a744dd42 --- /dev/null +++ b/framework/core/js/src/forum/components/AccessTokensList.tsx @@ -0,0 +1,197 @@ +import app from '../app'; +import Component, { ComponentAttrs } from '../../common/Component'; +import icon from '../../common/helpers/icon'; +import Button from '../../common/components/Button'; +import humanTime from '../../common/helpers/humanTime'; +import ItemList from '../../common/utils/ItemList'; +import LabelValue from '../../common/components/LabelValue'; +import extractText from '../../common/utils/extractText'; +import classList from '../../common/utils/classList'; +import Tooltip from '../../common/components/Tooltip'; +import type Mithril from 'mithril'; +import type AccessToken from '../../common/models/AccessToken'; +import { NestedStringArray } from '@askvortsov/rich-icu-message-formatter'; + +export interface IAccessTokensListAttrs extends ComponentAttrs { + tokens: AccessToken[]; + type: 'session' | 'developer_token'; + hideTokens?: boolean; + icon?: string; + ondelete?: (token: AccessToken) => void; +} + +export default class AccessTokensList extends Component { + protected loading: Record = {}; + protected showingTokens: Record = {}; + + view(vnode: Mithril.Vnode): Mithril.Children { + return ( +
+ {this.attrs.tokens.length ? ( + this.attrs.tokens.map(this.tokenView.bind(this)) + ) : ( +
{app.translator.trans('core.forum.security.empty_text')}
+ )} +
+ ); + } + + tokenView(token: AccessToken): Mithril.Children { + return ( +
+ {this.tokenViewItems(token).toArray()} +
+ ); + } + + tokenViewItems(token: AccessToken): ItemList { + const items = new ItemList(); + + items.add('icon',
{icon(this.attrs.icon || 'fas fa-key')}
, 50); + + items.add('info',
{this.tokenInfoItems(token).toArray()}
, 40); + + items.add('actions',
{this.tokenActionItems(token).toArray()}
, 30); + + return items; + } + + tokenInfoItems(token: AccessToken) { + const items = new ItemList(); + + if (this.attrs.type === 'session') { + items.add( + 'title', +
+ {token.device()} + {token.isCurrent() && [ + ' — ', + {app.translator.trans('core.forum.security.current_active_session')}, + ]} +
+ ); + } else { + items.add( + 'title', +
+ {this.generateTokenTitle(token)} +
+ ); + } + + items.add( + 'createdAt', +
+ +
+ ); + + items.add( + 'lastActivityAt', +
+ + {humanTime(token.lastActivityAt())} + {token.lastIpAddress() && ` — ${token.lastIpAddress()}`} + {this.attrs.type === 'developer_token' && token.device() && ( + <> + {' '} + — {token.device()} + + )} + + ) : ( + app.translator.trans('core.forum.security.never') + ) + } + /> +
+ ); + + return items; + } + + tokenActionItems(token: AccessToken) { + const items = new ItemList(); + + const deleteKey = { + session: 'terminate_session', + developer_token: 'revoke_access_token', + }[this.attrs.type]; + + if (this.attrs.type === 'developer_token') { + const isHidden = !this.showingTokens[token.id()!]; + const displayKey = isHidden ? 'show_access_token' : 'hide_access_token'; + + items.add( + 'toggleDisplay', + + ); + } + + let revokeButton = ( + + ); + + if (token.isCurrent()) { + revokeButton = ( + +
{revokeButton}
+
+ ); + } + + items.add('revoke', revokeButton); + + return items; + } + + async revoke(token: AccessToken) { + if (!confirm(extractText(app.translator.trans('core.forum.security.revoke_access_token_confirmation')))) return; + + this.loading[token.id()!] = true; + + await token.delete(); + + this.loading[token.id()!] = false; + this.attrs.ondelete?.(token); + + const key = this.attrs.type === 'session' ? 'session_terminated' : 'token_revoked'; + + app.alerts.show({ type: 'success' }, app.translator.trans(`core.forum.security.${key}`, { count: 1 })); + m.redraw(); + } + + generateTokenTitle(token: AccessToken): NestedStringArray { + const name = token.title() || app.translator.trans('core.forum.security.token_title_placeholder'); + const value = this.tokenValueDisplay(token); + + return app.translator.trans('core.forum.security.token_item_title', { name, value }); + } + + tokenValueDisplay(token: AccessToken): Mithril.Children { + const obfuscatedName = Array(12).fill('*').join(''); + const value = this.showingTokens[token.id()!] ? token.token() : obfuscatedName; + + return {value}; + } +} diff --git a/framework/core/js/src/forum/components/NewAccessTokenModal.tsx b/framework/core/js/src/forum/components/NewAccessTokenModal.tsx new file mode 100644 index 000000000..2734645e7 --- /dev/null +++ b/framework/core/js/src/forum/components/NewAccessTokenModal.tsx @@ -0,0 +1,64 @@ +import app from '../app'; +import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import Button from '../../common/components/Button'; +import Stream from '../../common/utils/Stream'; +import type AccessToken from '../../common/models/AccessToken'; +import type { SaveAttributes } from '../../common/Model'; +import type Mithril from 'mithril'; + +export interface INewAccessTokenModalAttrs extends IInternalModalAttrs { + onsuccess: (token: AccessToken) => void; +} + +export default class NewAccessTokenModal extends Modal { + protected titleInput = Stream(''); + + className(): string { + return 'Modal--small NewAccessTokenModal'; + } + + title(): Mithril.Children { + return app.translator.trans('core.forum.security.new_access_token_modal.title'); + } + + content(): Mithril.Children { + const titleLabel = app.translator.trans('core.forum.security.new_access_token_modal.title_placeholder'); + + return ( +
+
+
+ +
+
+ +
+
+
+ ); + } + + submitData(): SaveAttributes { + return { + title: this.titleInput(), + }; + } + + onsubmit(e: SubmitEvent) { + super.onsubmit(e); + e.preventDefault(); + + this.loading = true; + + app.store + .createRecord('access-tokens') + .save(this.submitData()) + .then((token) => { + this.attrs.onsuccess(token); + app.modal.close(); + }) + .finally(this.loaded.bind(this)); + } +} diff --git a/framework/core/js/src/forum/components/UserPage.tsx b/framework/core/js/src/forum/components/UserPage.tsx index 133c936c3..16d2c60f0 100644 --- a/framework/core/js/src/forum/components/UserPage.tsx +++ b/framework/core/js/src/forum/components/UserPage.tsx @@ -131,6 +131,7 @@ export default class UserPage(); const user = this.user!; + const isActor = app.session.user === user; items.add( 'posts', @@ -148,7 +149,7 @@ export default class UserPage, -90); items.add( 'settings', @@ -159,6 +160,20 @@ export default class UserPage('canModerateAccessTokens')) { + if (!isActor) { + items.add('security-separator', , -90); + } + + items.add( + 'security', + + {app.translator.trans('core.forum.user.security_link')} + , + -100 + ); + } + return items; } } diff --git a/framework/core/js/src/forum/components/UserSecurityPage.tsx b/framework/core/js/src/forum/components/UserSecurityPage.tsx new file mode 100644 index 000000000..96d97dd19 --- /dev/null +++ b/framework/core/js/src/forum/components/UserSecurityPage.tsx @@ -0,0 +1,199 @@ +import app from '../../forum/app'; +import UserPage, { IUserPageAttrs } from './UserPage'; +import ItemList from '../../common/utils/ItemList'; +import FieldSet from '../../common/components/FieldSet'; +import listItems from '../../common/helpers/listItems'; +import extractText from '../../common/utils/extractText'; +import AccessTokensList from './AccessTokensList'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import Button from '../../common/components/Button'; +import NewAccessTokenModal from './NewAccessTokenModal'; +import { camelCaseToSnakeCase } from '../../common/utils/string'; +import type AccessToken from '../../common/models/AccessToken'; +import type Mithril from 'mithril'; +import Tooltip from '../../common/components/Tooltip'; +import UserSecurityPageState from '../states/UserSecurityPageState'; + +/** + * The `UserSecurityPage` component displays the user's security control panel, in + * the context of their user profile. + */ +export default class UserSecurityPage extends UserPage { + state = new UserSecurityPageState(); + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + const routeUsername = m.route.param('username'); + + if (routeUsername !== app.session.user?.slug() && !app.forum.attribute('canModerateAccessTokens')) { + m.route.set('/'); + } + + this.loadUser(routeUsername); + + app.setTitle(extractText(app.translator.trans('core.forum.security.title'))); + + this.loadTokens(); + } + + content() { + return ( +
+
    {listItems(this.settingsItems().toArray())}
+
+ ); + } + + /** + * Build an item list for the user's settings controls. + */ + settingsItems() { + const items = new ItemList(); + + ['developerTokens', 'sessions'].forEach((section) => { + const sectionName = `${section}Items` as 'developerTokensItems' | 'sessionsItems'; + const sectionLocale = camelCaseToSnakeCase(section); + + items.add( + section, +
+ {this[sectionName]().toArray()} +
+ ); + }); + + return items; + } + + /** + * Build an item list for the user's access accessToken settings. + */ + developerTokensItems() { + const items = new ItemList(); + + items.add( + 'accessTokenList', + !this.state.hasLoadedTokens() ? ( + + ) : ( + { + this.state.removeToken(token); + m.redraw(); + }} + tokens={this.state.getDeveloperTokens()} + icon="fas fa-key" + hideTokens={false} + /> + ) + ); + + if (this.user!.id() === app.session.user!.id()) { + items.add( + 'newAccessToken', + + ); + } + + return items; + } + + /** + * Build an item list for the user's access accessToken settings. + */ + sessionsItems() { + const items = new ItemList(); + + items.add( + 'sessionsList', + !this.state.hasLoadedTokens() ? ( + + ) : ( + { + this.state.removeToken(token); + m.redraw(); + }} + tokens={this.state.getSessionTokens()} + icon="fas fa-laptop" + hideTokens={true} + /> + ) + ); + + if (this.user!.id() === app.session.user!.id()) { + const isDisabled = !this.state.hasOtherActiveSessions(); + + let terminateAllOthersButton = ( + + ); + + if (isDisabled) { + terminateAllOthersButton = ( + + {terminateAllOthersButton} + + ); + } + + items.add('terminateAllOtherSessions', terminateAllOthersButton); + } + + return items; + } + + loadTokens() { + return app.store + .find('access-tokens', { + filter: { user: this.user!.id()! }, + }) + .then((tokens) => { + this.state.setTokens(tokens); + m.redraw(); + }); + } + + terminateAllOtherSessions() { + if (!confirm(extractText(app.translator.trans('core.forum.security.terminate_all_other_sessions_confirmation')))) return; + + this.state.setLoading(true); + + return app + .request({ + method: 'DELETE', + url: app.forum.attribute('apiUrl') + '/sessions', + }) + .then(() => { + // Count terminated sessions first. + const count = this.state.getOtherSessionTokens().length; + + this.state.removeOtherSessionTokens(); + + app.alerts.show({ type: 'success' }, app.translator.trans('core.forum.security.session_terminated', { count })); + + m.redraw(); + }) + .catch(() => { + app.alerts.show({ type: 'error' }, app.translator.trans('core.forum.security.session_termination_failed')); + }) + .finally(() => this.state.setLoading(false)); + } +} diff --git a/framework/core/js/src/forum/routes.ts b/framework/core/js/src/forum/routes.ts index 497a89b86..42c1beaf9 100644 --- a/framework/core/js/src/forum/routes.ts +++ b/framework/core/js/src/forum/routes.ts @@ -9,6 +9,7 @@ import DiscussionPageResolver from './resolvers/DiscussionPageResolver'; import Discussion from '../common/models/Discussion'; import type Post from '../common/models/Post'; import type User from '../common/models/User'; +import UserSecurityPage from './components/UserSecurityPage'; /** * Helper functions to generate URLs to form pages. @@ -34,6 +35,7 @@ export default function (app: ForumApplication) { 'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage }, settings: { path: '/settings', component: SettingsPage }, + 'user.security': { path: '/u/:username/security', component: UserSecurityPage }, notifications: { path: '/notifications', component: NotificationsPage }, }; } diff --git a/framework/core/js/src/forum/states/UserSecurityPageState.ts b/framework/core/js/src/forum/states/UserSecurityPageState.ts new file mode 100644 index 000000000..4bf863969 --- /dev/null +++ b/framework/core/js/src/forum/states/UserSecurityPageState.ts @@ -0,0 +1,57 @@ +import AccessToken from '../../common/models/AccessToken'; + +export default class UserSecurityPageState { + protected tokens: AccessToken[] | null = null; + protected loading: boolean = false; + + public isLoading(): boolean { + return this.loading; + } + + public hasLoadedTokens(): boolean { + return this.tokens !== null; + } + + public setLoading(loading: boolean): void { + this.loading = loading; + } + + public getTokens(): AccessToken[] | null { + return this.tokens; + } + + public setTokens(tokens: AccessToken[]): void { + this.tokens = tokens; + } + + public pushToken(token: AccessToken): void { + this.tokens?.push(token); + } + + public removeToken(token: AccessToken): void { + this.tokens = this.tokens!.filter((t) => t !== token); + } + + public getSessionTokens(): AccessToken[] { + return this.tokens?.filter((token) => token.isSessionToken()).sort((a, b) => (b.isCurrent() ? 1 : -1)) || []; + } + + public getDeveloperTokens(): AccessToken[] | null { + return this.tokens?.filter((token) => !token.isSessionToken()) || null; + } + + /** + * Look up session tokens other than the current one. + */ + public getOtherSessionTokens(): AccessToken[] { + return this.tokens?.filter((token) => token.isSessionToken() && !token.isCurrent()) || []; + } + + public hasOtherActiveSessions(): boolean { + return (this.getOtherSessionTokens() || []).length > 0; + } + + public removeOtherSessionTokens() { + this.tokens = this.tokens!.filter((token) => !token.isSessionToken() || token.isCurrent()); + } +} diff --git a/framework/core/less/common/Button.less b/framework/core/less/common/Button.less index 32169af6d..b0f54ee23 100644 --- a/framework/core/less/common/Button.less +++ b/framework/core/less/common/Button.less @@ -164,6 +164,9 @@ display: none; } } +.Button--inverted { + .Button--color-auto('button-inverted'); +} .Button--danger { .Button--color-auto('control-danger'); } diff --git a/framework/core/less/common/LabelValue.less b/framework/core/less/common/LabelValue.less new file mode 100644 index 000000000..9e0414991 --- /dev/null +++ b/framework/core/less/common/LabelValue.less @@ -0,0 +1,8 @@ +.LabelValue { + display: flex; + gap: 4px; + + &-label { + font-weight: bold; + } +} diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less index ceb03a732..92f1738d7 100644 --- a/framework/core/less/common/common.less +++ b/framework/core/less/common/common.less @@ -14,6 +14,7 @@ @import "Button"; @import "Checkbox"; @import "ColorInput"; +@import "LabelValue"; @import "Dropdown"; @import "EditUserModal"; @import "Form"; diff --git a/framework/core/less/forum.less b/framework/core/less/forum.less index 16c1c1604..fa4da3148 100644 --- a/framework/core/less/forum.less +++ b/framework/core/less/forum.less @@ -17,6 +17,7 @@ @import "forum/Post"; @import "forum/PostStream"; @import "forum/Scrubber"; +@import "forum/UserSecurityPage"; @import "forum/SettingsPage"; @import "forum/SignUpModal"; @import "forum/Slidable"; diff --git a/framework/core/less/forum/UserSecurityPage.less b/framework/core/less/forum/UserSecurityPage.less new file mode 100644 index 000000000..8db4af601 --- /dev/null +++ b/framework/core/less/forum/UserSecurityPage.less @@ -0,0 +1,91 @@ +.UserSecurityPage { + > ul, fieldset > ul { + list-style: none; + margin: 0; + padding: 0; + } + + > ul > li { + margin-bottom: 25px; + } +} + +.AccessTokensList { + display: flex; + flex-direction: column; + border-radius: var(--border-radius); + overflow: hidden; + + > *:not(:first-child) { + margin-left: 1px; + } + + &-item { + display: flex; + padding: 16px 16px 16px 0; + background-color: var(--control-bg); + color: var(--control-color); + + &-icon { + --font-size: 1.6rem; + font-size: var(--font-size); + width: calc(~"var(--font-size) + 4rem"); + display: flex; + align-items: center; + justify-content: center; + } + + &-title { + font-weight: bold; + + &-sub { + font-style: italic; + } + } + + &-actions { + display: flex; + align-items: center; + margin-left: auto; + + > *:not(:first-child) { + margin-left: 8px; + } + } + + &--active &-title-sub { + color: var(--alert-success-color); + } + } + + &--empty { + color: var(--control-color); + } +} + +@media @phone { + .AccessTokensList { + > *:not(:first-child) { + margin-left: 8px; + } + + &-item { + flex-wrap: wrap; + padding: 16px; + + > *:not(:first-child) { + margin-left: 16px; + } + + &-icon { + width: 100%; + justify-content: start; + padding: 8px; + } + + &-actions { + width: 100%; + } + } + } +} diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 76092ba4e..007fb0f07 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -191,6 +191,7 @@ core: permissions: allow_post_editing_label: Allow post editing allow_renaming_label: Allow renaming + create_access_token_label: Create access token create_heading: Create delete_discussions_forever_label: Delete discussions forever delete_discussions_label: Delete discussions @@ -203,6 +204,7 @@ core: edit_users_groups_label: Edit user groups global_heading: Global moderate_heading: Moderate + moderate_access_tokens_label: Moderate Access Tokens new_group_button: New Group participate_heading: Participate post_without_throttle_label: Reply multiple times without waiting @@ -468,6 +470,36 @@ core: discussions_heading: => core.ref.discussions users_heading: => core.ref.users + # These translations are used in the Security page. + security: + developer_tokens_heading: Developer Tokens + current_active_session: Current Active Session + browser_on_operating_system: "{browser} on {os}" + cannot_terminate_current_session: Cannot terminate the current active session. Log out instead. + created: Created + hide_access_token: Hide Token + last_activity: Last activity + never: Never + new_access_token_button: => core.ref.new_token + new_access_token_modal: + submit_button: Create Token + title: => core.ref.new_token + title_placeholder: Title + empty_text: It looks like there is nothing to see here. + revoke_access_token: Revoke + revoke_access_token_confirmation: => core.ref.generic_confirmation_message + sessions_heading: Active Sessions + session_terminated: "{count, plural, one {Session terminated} other {# Sessions terminated}}." + session_termination_failed: "An error occurred while terminating your sessions." + show_access_token: View Token + terminate_all_other_sessions: Terminate all other sessions + terminate_all_other_sessions_confirmation: => core.ref.generic_confirmation_message + terminate_session: Terminate + title: => core.ref.security + token_revoked: Token revoked. + token_item_title: "{title} - {token}" + token_title_placeholder: "/" + # These translations are used in the Settings page. settings: account_heading: Account @@ -505,6 +537,7 @@ core: posts_empty_text: It looks like there are no posts here. posts_link: => core.ref.posts posts_load_more_button: => core.ref.load_more + security_link: => core.ref.security settings_link: => core.ref.settings # These translations are found on the user profile page (admin function). @@ -542,6 +575,10 @@ core: dropdown: toggle_dropdown_accessible_label: Toggle dropdown menu + # These translations are used in the data segment component. + data_segment: + label: "{label}:" + # These translations are used in the Edit User modal dialog (admin function). edit_user: activate_button: Activate User @@ -747,6 +784,7 @@ core: edit_user: Edit User email: Email extensions: Extensions + generic_confirmation_message: "Are you sure you want to proceed? This action cannot be undone." icon: Icon icon_text: "Enter the name of any FontAwesome icon class, including the fas fa- prefix." load_more: Load More @@ -754,6 +792,7 @@ core: log_in: Log In log_out: Log Out mark_all_as_read: Mark All as Read + new_token: New Token next_page: Next Page notifications: Notifications okay: OK # Referenced by flarum-tags.yml @@ -765,8 +804,9 @@ core: reply: Reply # Referenced by flarum-mentions.yml reset_your_password: Reset Your Password restore: Restore - save_changes: Save Changes + save_changes: Save Changes search_users: Search users # Referenced by flarum-suspend.yml, flarum-tags.yml + security: Security settings: Settings sign_up: Sign Up some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml diff --git a/framework/core/migrations/2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable.php b/framework/core/migrations/2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable.php new file mode 100644 index 000000000..91e9778dd --- /dev/null +++ b/framework/core/migrations/2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable.php @@ -0,0 +1,25 @@ + function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + $table->dateTime('last_activity_at')->nullable()->change(); + }); + }, + + 'down' => function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + // Making last_activity_at not nullable is not possible because it would mess up existing data. + }); + } +]; diff --git a/framework/core/src/Api/Controller/CreateAccessTokenController.php b/framework/core/src/Api/Controller/CreateAccessTokenController.php new file mode 100644 index 000000000..10260ad14 --- /dev/null +++ b/framework/core/src/Api/Controller/CreateAccessTokenController.php @@ -0,0 +1,73 @@ +events = $events; + $this->validation = $validation; + } + + /** + * {@inheritdoc} + */ + public function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + + $actor->assertRegistered(); + $actor->assertCan('createAccessToken'); + + $title = Arr::get($request->getParsedBody(), 'data.attributes.title'); + + $this->validation->make(compact('title'), [ + 'title' => 'required|string|max:255', + ])->validate(); + + $token = DeveloperAccessToken::generate($actor->id); + + $token->title = $title; + $token->last_activity_at = null; + + $token->save(); + + $this->events->dispatch(new DeveloperTokenCreated($token)); + + return $token; + } +} diff --git a/framework/core/src/Api/Controller/CreateTokenController.php b/framework/core/src/Api/Controller/CreateTokenController.php index 197bdc76d..a60a27b21 100644 --- a/framework/core/src/Api/Controller/CreateTokenController.php +++ b/framework/core/src/Api/Controller/CreateTokenController.php @@ -21,6 +21,11 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Not to be confused with the CreateAccessTokenController, + * this controller is used to authenticate a user with credentials, + * and return a system generated session-type access token. + */ class CreateTokenController implements RequestHandlerInterface { /** diff --git a/framework/core/src/Api/Controller/DeleteAccessTokenController.php b/framework/core/src/Api/Controller/DeleteAccessTokenController.php new file mode 100644 index 000000000..739901250 --- /dev/null +++ b/framework/core/src/Api/Controller/DeleteAccessTokenController.php @@ -0,0 +1,52 @@ +getQueryParams(), 'id'); + + $actor->assertRegistered(); + + $token = AccessToken::query()->findOrFail($id); + + /** @var Session|null $session */ + $session = $request->getAttribute('session'); + + // Current session should only be terminated through logout. + if ($session && $token->token === $session->get('access_token')) { + throw new PermissionDeniedException(); + } + + // Don't give away the existence of the token. + if ($actor->cannot('revoke', $token)) { + throw new ModelNotFoundException(); + } + + $token->delete(); + + return new EmptyResponse(204); + } +} diff --git a/framework/core/src/Api/Controller/ListAccessTokensController.php b/framework/core/src/Api/Controller/ListAccessTokensController.php new file mode 100644 index 000000000..bad5b5135 --- /dev/null +++ b/framework/core/src/Api/Controller/ListAccessTokensController.php @@ -0,0 +1,65 @@ +url = $url; + $this->filterer = $filterer; + } + + /** + * {@inheritdoc} + */ + protected function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + + $actor->assertRegistered(); + + $offset = $this->extractOffset($request); + $limit = $this->extractLimit($request); + $filter = $this->extractFilter($request); + + $tokens = $this->filterer->filter(new QueryCriteria($actor, $filter), $limit, $offset); + + $document->addPaginationLinks( + $this->url->to('api')->route('access-tokens.index'), + $request->getQueryParams(), + $offset, + $limit, + $tokens->areMoreResults() ? null : 0 + ); + + return $tokens->getResults(); + } +} diff --git a/framework/core/src/Api/Controller/TerminateAllOtherSessionsController.php b/framework/core/src/Api/Controller/TerminateAllOtherSessionsController.php new file mode 100644 index 000000000..d2280da7a --- /dev/null +++ b/framework/core/src/Api/Controller/TerminateAllOtherSessionsController.php @@ -0,0 +1,45 @@ +assertRegistered(); + + $session = $request->getAttribute('session'); + $sessionAccessToken = $session ? $session->get('access_token') : null; + + // Delete all session access tokens except for this one. + $actor + ->accessTokens() + ->where('token', '!=', $sessionAccessToken) + ->where(function (Builder $query) { + $query + ->where('type', SessionAccessToken::$type) + ->orWhere('type', RememberAccessToken::$type); + })->delete(); + + return new EmptyResponse(204); + } +} diff --git a/framework/core/src/Api/Serializer/AccessTokenSerializer.php b/framework/core/src/Api/Serializer/AccessTokenSerializer.php new file mode 100644 index 000000000..9caaf4ae1 --- /dev/null +++ b/framework/core/src/Api/Serializer/AccessTokenSerializer.php @@ -0,0 +1,73 @@ +translator = $translator; + } + + /** + * @param AccessToken $token + */ + protected function getDefaultAttributes($token) + { + $session = $this->request->getAttribute('session'); + + $agent = new Agent(); + $agent->setUserAgent($token->last_user_agent); + + $attributes = [ + 'token' => $token->token, + 'userId' => $token->user_id, + 'createdAt' => $this->formatDate($token->created_at), + 'lastActivityAt' => $this->formatDate($token->last_activity_at), + 'isCurrent' => $session && $session->get('access_token') === $token->token, + 'isSessionToken' => in_array($token->type, ['session', 'session_remember'], true), + 'title' => $token->title, + 'lastIpAddress' => $token->last_ip_address, + 'device' => $this->translator->trans('core.forum.security.browser_on_operating_system', [ + 'browser' => $agent->browser(), + 'os' => $agent->platform(), + ]), + ]; + + // Unset hidden attributes (like the token value on session tokens) + foreach ($token->getHidden() as $name) { + unset($attributes[$name]); + } + + // Hide the token value to non-actors no matter who they are. + if (isset($attributes['token']) && $this->getActor()->id !== $token->user_id) { + unset($attributes['token']); + } + + return $attributes; + } +} diff --git a/framework/core/src/Api/Serializer/ForumSerializer.php b/framework/core/src/Api/Serializer/ForumSerializer.php index c223ea281..3e40dd58e 100644 --- a/framework/core/src/Api/Serializer/ForumSerializer.php +++ b/framework/core/src/Api/Serializer/ForumSerializer.php @@ -93,6 +93,8 @@ class ForumSerializer extends AbstractSerializer 'canViewForum' => $this->actor->can('viewForum'), 'canStartDiscussion' => $this->actor->can('startDiscussion'), 'canSearchUsers' => $this->actor->can('searchUsers'), + 'canCreateAccessToken' => $this->actor->can('createAccessToken'), + 'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'), 'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'), ]; diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index e21141107..86d2d6aca 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -19,6 +19,27 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\ShowForumController::class) ); + // List access tokens + $map->get( + '/access-tokens', + 'access-tokens.index', + $route->toController(Controller\ListAccessTokensController::class) + ); + + // List access tokens + $map->post( + '/access-tokens', + 'access-tokens.create', + $route->toController(Controller\CreateAccessTokenController::class) + ); + + // List access tokens + $map->delete( + '/access-tokens/{id}', + 'access-tokens.delete', + $route->toController(Controller\DeleteAccessTokenController::class) + ); + // Retrieve authentication token $map->post( '/token', @@ -26,6 +47,13 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\CreateTokenController::class) ); + // Terminate all other sessions + $map->delete( + '/sessions', + 'sessions.delete', + $route->toController(Controller\TerminateAllOtherSessionsController::class) + ); + // Send forgot password email $map->post( '/forgot', @@ -71,7 +99,7 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $map->delete( '/users/{id}', 'users.delete', - $route->toController(Controller\DeleteUserController::class) + $route->toController(Controller\DeleteAccessTokenController::class) ); // Upload avatar diff --git a/framework/core/src/Filter/FilterServiceProvider.php b/framework/core/src/Filter/FilterServiceProvider.php index 19a222361..46cffadbf 100644 --- a/framework/core/src/Filter/FilterServiceProvider.php +++ b/framework/core/src/Filter/FilterServiceProvider.php @@ -15,6 +15,8 @@ use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ContainerUtil; use Flarum\Group\Filter as GroupFilter; use Flarum\Group\Filter\GroupFilterer; +use Flarum\Http\Filter\AccessTokenFilterer; +use Flarum\Http\Filter as HttpFilter; use Flarum\Post\Filter as PostFilter; use Flarum\Post\Filter\PostFilterer; use Flarum\User\Filter\UserFilterer; @@ -33,6 +35,9 @@ class FilterServiceProvider extends AbstractServiceProvider { $this->container->singleton('flarum.filter.filters', function () { return [ + AccessTokenFilterer::class => [ + HttpFilter\UserFilter::class, + ], DiscussionFilterer::class => [ DiscussionQuery\AuthorFilterGambit::class, DiscussionQuery\CreatedFilterGambit::class, diff --git a/framework/core/src/Http/Access/AccessTokenPolicy.php b/framework/core/src/Http/Access/AccessTokenPolicy.php new file mode 100644 index 000000000..5346ae627 --- /dev/null +++ b/framework/core/src/Http/Access/AccessTokenPolicy.php @@ -0,0 +1,24 @@ +user_id === $actor->id || $actor->hasPermission('moderateAccessTokens')) { + return $this->allow(); + } + } +} diff --git a/framework/core/src/Http/Access/ScopeAccessTokenVisibility.php b/framework/core/src/Http/Access/ScopeAccessTokenVisibility.php new file mode 100644 index 000000000..7fd4f5c5c --- /dev/null +++ b/framework/core/src/Http/Access/ScopeAccessTokenVisibility.php @@ -0,0 +1,29 @@ +isGuest()) { + $query->whereRaw('FALSE'); + } elseif (! $actor->hasPermission('moderateAccessTokens')) { + $query->where('user_id', $actor->id); + } + } +} diff --git a/framework/core/src/Http/AccessToken.php b/framework/core/src/Http/AccessToken.php index 41b3e60bf..56c30e37c 100644 --- a/framework/core/src/Http/AccessToken.php +++ b/framework/core/src/Http/AccessToken.php @@ -11,6 +11,7 @@ namespace Flarum\Http; use Carbon\Carbon; use Flarum\Database\AbstractModel; +use Flarum\Database\ScopeVisibilityTrait; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; @@ -31,6 +32,8 @@ use Psr\Http\Message\ServerRequestInterface; */ class AccessToken extends AbstractModel { + use ScopeVisibilityTrait; + protected $table = 'access_tokens'; protected $dates = [ diff --git a/framework/core/src/Http/Event/DeveloperTokenCreated.php b/framework/core/src/Http/Event/DeveloperTokenCreated.php new file mode 100644 index 000000000..846077429 --- /dev/null +++ b/framework/core/src/Http/Event/DeveloperTokenCreated.php @@ -0,0 +1,25 @@ +token = $token; + } +} diff --git a/framework/core/src/Http/Filter/AccessTokenFilterer.php b/framework/core/src/Http/Filter/AccessTokenFilterer.php new file mode 100644 index 000000000..c55df90d8 --- /dev/null +++ b/framework/core/src/Http/Filter/AccessTokenFilterer.php @@ -0,0 +1,23 @@ +whereVisibleTo($actor); + } +} diff --git a/framework/core/src/Http/Filter/UserFilter.php b/framework/core/src/Http/Filter/UserFilter.php new file mode 100644 index 000000000..d206fbaf9 --- /dev/null +++ b/framework/core/src/Http/Filter/UserFilter.php @@ -0,0 +1,38 @@ +getQuery()->where('user_id', $negate ? '!=' : '=', $filterValue); + } +} diff --git a/framework/core/src/Http/HttpServiceProvider.php b/framework/core/src/Http/HttpServiceProvider.php index 26f0f6593..fd555cd6d 100644 --- a/framework/core/src/Http/HttpServiceProvider.php +++ b/framework/core/src/Http/HttpServiceProvider.php @@ -13,6 +13,7 @@ use Flarum\Discussion\Discussion; use Flarum\Discussion\IdWithTransliteratedSlugDriver; use Flarum\Discussion\Utf8SlugDriver; use Flarum\Foundation\AbstractServiceProvider; +use Flarum\Http\Access\ScopeAccessTokenVisibility; use Flarum\Settings\SettingsRepositoryInterface; use Flarum\User\IdSlugDriver; use Flarum\User\User; @@ -74,6 +75,8 @@ class HttpServiceProvider extends AbstractServiceProvider public function boot() { $this->setAccessTokenTypes(); + + AccessToken::registerVisibilityScoper(new ScopeAccessTokenVisibility(), 'view'); } protected function setAccessTokenTypes() diff --git a/framework/core/src/Http/RememberAccessToken.php b/framework/core/src/Http/RememberAccessToken.php index 9a091fb04..33c971d2b 100644 --- a/framework/core/src/Http/RememberAccessToken.php +++ b/framework/core/src/Http/RememberAccessToken.php @@ -15,6 +15,8 @@ class RememberAccessToken extends AccessToken protected static $lifetime = 5 * 365 * 24 * 60 * 60; // 5 years + protected $hidden = ['token']; + /** * Just a helper method so we can re-use the lifetime value which is protected. * @return int diff --git a/framework/core/src/Http/SessionAccessToken.php b/framework/core/src/Http/SessionAccessToken.php index 4cf162efb..12cb7fd67 100644 --- a/framework/core/src/Http/SessionAccessToken.php +++ b/framework/core/src/Http/SessionAccessToken.php @@ -14,4 +14,6 @@ class SessionAccessToken extends AccessToken public static $type = 'session'; protected static $lifetime = 60 * 60; // 1 hour + + protected $hidden = ['token']; } diff --git a/framework/core/src/User/UserServiceProvider.php b/framework/core/src/User/UserServiceProvider.php index 9898e2c35..abe37efb3 100644 --- a/framework/core/src/User/UserServiceProvider.php +++ b/framework/core/src/User/UserServiceProvider.php @@ -15,6 +15,8 @@ use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ContainerUtil; use Flarum\Group\Access\GroupPolicy; use Flarum\Group\Group; +use Flarum\Http\Access\AccessTokenPolicy; +use Flarum\Http\AccessToken; use Flarum\Post\Access\PostPolicy; use Flarum\Post\Post; use Flarum\Settings\SettingsRepositoryInterface; @@ -48,6 +50,7 @@ class UserServiceProvider extends AbstractServiceProvider $this->container->singleton('flarum.policies', function () { return [ Access\AbstractPolicy::GLOBAL => [], + AccessToken::class => [AccessTokenPolicy::class], Discussion::class => [DiscussionPolicy::class], Group::class => [GroupPolicy::class], Post::class => [PostPolicy::class], diff --git a/framework/core/tests/integration/api/access_tokens/CreateTest.php b/framework/core/tests/integration/api/access_tokens/CreateTest.php new file mode 100644 index 000000000..e87133966 --- /dev/null +++ b/framework/core/tests/integration/api/access_tokens/CreateTest.php @@ -0,0 +1,116 @@ +prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'normal3', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1] + ], + 'access_tokens' => [], + 'groups' => [ + ['id' => 10, 'name_plural' => 'Acme', 'name_singular' => 'Acme'] + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 10] + ], + 'group_permission' => [ + ['permission' => 'createAccessToken', 'group_id' => 10] + ], + ]); + } + + /** + * @dataProvider canCreateTokens + * @test + */ + public function user_can_create_developer_tokens(int $authenticatedAs) + { + $response = $this->send( + $this->request('POST', '/api/access-tokens', [ + 'authenticatedAs' => $authenticatedAs, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'title' => 'Dev' + ] + ] + ] + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + } + + /** + * @dataProvider cannotCreateTokens + * @test + */ + public function user_cannot_delete_other_users_tokens(int $authenticatedAs) + { + $response = $this->send( + $this->request('POST', '/api/access-tokens', [ + 'authenticatedAs' => $authenticatedAs, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'title' => 'Dev' + ] + ] + ] + ]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_cannot_create_token_without_title() + { + $response = $this->send( + $this->request('POST', '/api/access-tokens', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(422, $response->getStatusCode()); + } + + public function canCreateTokens(): array + { + return [ + [1], // Admin + [3], // User with permission + ]; + } + + public function cannotCreateTokens(): array + { + return [ + [2] + ]; + } +} diff --git a/framework/core/tests/integration/api/access_tokens/DeleteTest.php b/framework/core/tests/integration/api/access_tokens/DeleteTest.php new file mode 100644 index 000000000..b12b9dbd1 --- /dev/null +++ b/framework/core/tests/integration/api/access_tokens/DeleteTest.php @@ -0,0 +1,210 @@ +prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'normal3', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'normal4', 'email' => 'normal4@machine.local', 'is_email_confirmed' => 1], + ], + 'access_tokens' => [ + ['id' => 1, 'token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['id' => 2, 'token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'], + ['id' => 3, 'token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ['id' => 4, 'token' => 'd', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ['id' => 5, 'token' => 'e', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['id' => 6, 'token' => 'f', 'user_id' => 3, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ], + 'groups' => [ + ['id' => 100, 'name_singular' => 'test', 'name_plural' => 'test'] + ], + 'group_user' => [ + ['user_id' => 4, 'group_id' => 100] + ], + 'group_permission' => [ + ['group_id' => 100, 'permission' => 'moderateAccessTokens'] + ] + ]); + } + + /** + * @dataProvider canDeleteTokensDataProvider + * @test + */ + public function user_can_delete_tokens(int $authenticatedAs, array $canDeleteIds) + { + foreach ($canDeleteIds as $id) { + $response = $this->send( + $this->request('DELETE', "/api/access-tokens/$id", compact('authenticatedAs')) + ); + + $this->assertEquals(204, $response->getStatusCode()); + } + } + + /** + * @dataProvider cannotDeleteTokensDataProvider + * @test + */ + public function user_cannot_delete_tokens(int $authenticatedAs, array $canDeleteIds) + { + foreach ($canDeleteIds as $id) { + $response = $this->send( + $this->request('DELETE', "/api/access-tokens/$id", compact('authenticatedAs')) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + } + + /** + * @test + */ + public function user_cannot_delete_current_session_token() + { + $responseWithSession = $this->send( + $this->requestWithCsrfToken( + $this->request('POST', '/login', [ + 'json' => [ + 'identification' => 'admin', + 'password' => 'password', + ] + ]) + ) + ); + + $sessionToken = AccessToken::query() + ->where('user_id', 1) + ->where('type', SessionAccessToken::$type) + ->latest() + ->first(); + + $csrfToken = $responseWithSession->getHeaderLine('X-CSRF-Token'); + + $request = $this->requestWithCookiesFrom( + $this->request('DELETE', "/api/access-tokens/$sessionToken->id")->withHeader('X-CSRF-Token', $csrfToken), + $responseWithSession + ); + + $response = $this->send($request); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_can_terminate_all_other_sessions() + { + $responseWithSession = $this->send( + $this->requestWithCsrfToken( + $this->request('POST', '/login', [ + 'json' => [ + 'identification' => 'admin', + 'password' => 'password', + ] + ]) + ) + ); + + $sessionToken = AccessToken::query() + ->where('user_id', 1) + ->where('type', SessionAccessToken::$type) + ->latest() + ->first(); + + $csrfToken = $responseWithSession->getHeaderLine('X-CSRF-Token'); + + $request = $this->requestWithCookiesFrom( + $this->request('DELETE', '/api/sessions')->withHeader('X-CSRF-Token', $csrfToken), + $responseWithSession + ); + + $response = $this->send($request); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertEquals( + 1, // It doesn't delete current session + AccessToken::query() + ->where('user_id', 1) + ->where(function ($query) { + $query + ->where('type', SessionAccessToken::$type) + ->orWhere('type', RememberAccessToken::$type); + }) + ->count() + ); + } + + /** + * @test + */ + public function terminting_all_other_sessions_does_not_delete_dev_tokens() + { + $response = $this->send( + $this->request('DELETE', '/api/sessions', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertEquals( + 1, + AccessToken::query() + ->where('user_id', 1) + ->where('type', DeveloperAccessToken::$type) + ->count() + ); + } + + public function canDeleteTokensDataProvider(): array + { + return [ + // Admin can delete any user tokens. + [1, [1, 2, 3, 4, 5, 6]], + + // User with moderateAccessTokens permission can delete any tokens. + [4, [1, 2, 3, 4, 5, 6]], + + // Normal users can only delete their own. + [2, [4, 5]], + [3, [6]], + ]; + } + + public function cannotDeleteTokensDataProvider(): array + { + return [ + // Normal users cannot delete other users' tokens. + [2, [1, 2]], + [3, [1, 4]], + ]; + } +} diff --git a/framework/core/tests/integration/api/access_tokens/ListTest.php b/framework/core/tests/integration/api/access_tokens/ListTest.php new file mode 100644 index 000000000..2a077bedb --- /dev/null +++ b/framework/core/tests/integration/api/access_tokens/ListTest.php @@ -0,0 +1,184 @@ +prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'normal3', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'normal4', 'email' => 'normal4@machine.local', 'is_email_confirmed' => 1], + ], + 'access_tokens' => [ + ['id' => 1, 'token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['id' => 2, 'token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'], + ['id' => 3, 'token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ['id' => 4, 'token' => 'd', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ['id' => 5, 'token' => 'e', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ['id' => 6, 'token' => 'f', 'user_id' => 3, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ], + 'groups' => [ + ['id' => 100, 'name_singular' => 'test', 'name_plural' => 'test'] + ], + 'group_user' => [ + ['user_id' => 4, 'group_id' => 100] + ], + 'group_permission' => [ + ['group_id' => 100, 'permission' => 'moderateAccessTokens'] + ] + ]); + } + + /** + * @dataProvider canViewTokensDataProvider + * @test + */ + public function user_can_view_access_tokens(int $authenticatedAs, array $canViewIds) + { + $response = $this->send( + $request = $this->request('GET', '/api/access-tokens', compact('authenticatedAs')) + ); + + $data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data'); + + $testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEqualsCanonicalizing(array_merge($canViewIds, [$testsTokenId]), Arr::pluck($data, 'id')); + } + + /** + * @dataProvider cannotSeeTokenValuesDataProvider + * @test + */ + public function user_cannot_see_token_values(int $authenticatedAs, ?int $userId, array $tokenValues) + { + if ($userId) { + $filters = [ + 'filter' => ['user' => $userId] + ]; + } + + $response = $this->send( + $this + ->request('GET', '/api/access-tokens', compact('authenticatedAs')) + ->withQueryParams($filters ?? []) + ); + + $data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data'); + + // There is always an additional null value to refer to the current session. + if (! $userId || $authenticatedAs === $userId) { + $tokenValues[] = null; + } + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEqualsCanonicalizing($tokenValues, Arr::pluck($data, 'attributes.token')); + } + + /** + * @dataProvider needsPermissionToUseUserfilterDataProvider + * @test + */ + public function user_needs_permissions_to_use_user_filter(int $authenticatedAs, int $userId, array $canViewIds) + { + $response = $this->send( + $request = $this->request('GET', '/api/access-tokens', compact('authenticatedAs')) + ->withQueryParams([ + 'filter' => ['user' => $userId] + ]) + ); + + $data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data'); + $testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id; + + if ($authenticatedAs === $userId) { + $canViewIds[] = $testsTokenId; + } + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEqualsCanonicalizing($canViewIds, Arr::pluck($data, 'id')); + } + + public function canViewTokensDataProvider(): array + { + return [ + // Admin can view his and others access tokens. + [1, [1, 2, 3, 4, 5, 6]], + + // User with moderateAccessTokens permission can view other users access tokens. + [4, [1, 2, 3, 4, 5, 6]], + + // Normal users can only view their own. + [2, [4, 5]], + [3, [6]], + ]; + } + + public function cannotSeeTokenValuesDataProvider(): array + { + return [ + // Admin can only see his own developer token value. + [1, null, [null, null, null, null, null, 'c']], + [1, 1, [null, null, 'c']], + [1, 2, [null, null]], + [1, 3, [null]], + + // User with moderateAccessTokens permission can only see his own developer token value. + [4, null, [null, null, null, null, null, null]], + [4, 1, [null, null, null]], + [4, 2, [null, null]], + [4, 3, [null]], + + // Normal users can only see their own developer token. + [2, null, ['d', 'e']], + [3, null, ['f']], + ]; + } + + public function needsPermissionToUseUserfilterDataProvider(): array + { + return [ + // Admin can use user filter. + [1, 1, [1, 2, 3]], + [1, 2, [4, 5]], + [1, 3, [6]], + [1, 4, []], + + // User with moderateAccessTokens permission can use user filter. + [4, 1, [1, 2, 3]], + [4, 2, [4, 5]], + [4, 3, [6]], + [4, 4, []], + + // Normal users cannot use the user filter + [2, 1, []], + [2, 2, [5, 4]], + [3, 2, []], + [3, 3, [6]], + ]; + } +} diff --git a/php-packages/testing/src/integration/BuildsHttpRequests.php b/php-packages/testing/src/integration/BuildsHttpRequests.php index 14308eb58..43ed2cfe0 100644 --- a/php-packages/testing/src/integration/BuildsHttpRequests.php +++ b/php-packages/testing/src/integration/BuildsHttpRequests.php @@ -49,7 +49,10 @@ trait BuildsHttpRequests 'type' => 'session' ]); - return $req->withAddedHeader('Authorization', "Token {$token}"); + return $req + ->withAddedHeader('Authorization', "Token {$token}") + // We save the token as an attribute so that we can retrieve it for test purposes. + ->withAttribute('tests_token', $token); } protected function requestWithCookiesFrom(Request $req, Response $previous): Request diff --git a/yarn.lock b/yarn.lock index 53caa985c..d385fd2b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1559,6 +1559,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"