feat: access tokens user management UI (#3587)

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
Co-authored-by: David <hi@davwheat.dev>
This commit is contained in:
Sami Mazouz 2023-02-21 14:14:53 +01:00 committed by GitHub
parent ea7b270f47
commit 9342903d68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1821 additions and 5 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -236,6 +236,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
90
);
items.add(
'createAccessToken',
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.create_access_token_label'),
permission: 'createAccessToken',
},
80
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
return items;
@ -396,6 +406,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
60
);
items.add(
'moderateAccessTokens',
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.moderate_access_tokens_label'),
permission: 'moderateAccessTokens',
},
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items;

View File

@ -36,6 +36,7 @@ import Model, { SavedModelData } from './Model';
import fireApplicationError from './helpers/fireApplicationError';
import IHistory from './IHistory';
import IExtender from './extenders/IExtender';
import AccessToken from './models/AccessToken';
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
@ -178,6 +179,7 @@ export default class Application {
* The app's data store.
*/
store: Store = new Store({
'access-tokens': AccessToken,
forums: Forum,
users: User,
discussions: Discussion,

View File

@ -0,0 +1,29 @@
import Component, { ComponentAttrs } from '../Component';
import type Mithril from 'mithril';
import app from '../app';
export interface ILabelValueAttrs extends ComponentAttrs {
label: Mithril.Children;
value: Mithril.Children;
}
/**
* A generic component for displaying a label and value inline.
* Created to avoid reinventing the wheel.
*
* `label: value`
*/
export default class LabelValue<CustomAttrs extends ILabelValueAttrs = ILabelValueAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
return (
<div className="LabelValue">
<div className="LabelValue-label">
{app.translator.trans('core.lib.data_segment.label', {
label: this.attrs.label,
})}
</div>
<div className="LabelValue-value">{this.attrs.value}</div>
</div>
);
}
}

View File

@ -0,0 +1,34 @@
import Model from '../Model';
export default class AccessToken extends Model {
token() {
return Model.attribute<string | undefined>('token').call(this);
}
userId() {
return Model.attribute<string>('userId').call(this);
}
title() {
return Model.attribute<string | null>('title').call(this);
}
type() {
return Model.attribute<string>('type').call(this);
}
createdAt() {
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
}
lastActivityAt() {
return Model.attribute<Date, string>('lastActivityAt', Model.transformDate).call(this);
}
lastIpAddress() {
return Model.attribute<string>('lastIpAddress').call(this);
}
device() {
return Model.attribute<string>('device').call(this);
}
isCurrent() {
return Model.attribute<boolean>('isCurrent').call(this);
}
isSessionToken() {
return Model.attribute<boolean>('isSessionToken').call(this);
}
}

View File

@ -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()}`);
}

View File

@ -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<CustomAttrs extends IAccessTokensListAttrs = IAccessTokensListAttrs> extends Component<CustomAttrs> {
protected loading: Record<string, boolean | undefined> = {};
protected showingTokens: Record<string, boolean | undefined> = {};
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
return (
<div className="AccessTokensList">
{this.attrs.tokens.length ? (
this.attrs.tokens.map(this.tokenView.bind(this))
) : (
<div className="AccessTokensList--empty">{app.translator.trans('core.forum.security.empty_text')}</div>
)}
</div>
);
}
tokenView(token: AccessToken): Mithril.Children {
return (
<div
className={classList('AccessTokensList-item', {
'AccessTokensList-item--active': token.isCurrent(),
})}
key={token.id()!}
>
{this.tokenViewItems(token).toArray()}
</div>
);
}
tokenViewItems(token: AccessToken): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('icon', <div className="AccessTokensList-item-icon">{icon(this.attrs.icon || 'fas fa-key')}</div>, 50);
items.add('info', <div className="AccessTokensList-item-info">{this.tokenInfoItems(token).toArray()}</div>, 40);
items.add('actions', <div className="AccessTokensList-item-actions">{this.tokenActionItems(token).toArray()}</div>, 30);
return items;
}
tokenInfoItems(token: AccessToken) {
const items = new ItemList<Mithril.Children>();
if (this.attrs.type === 'session') {
items.add(
'title',
<div className="AccessTokensList-item-title">
<span className="AccessTokensList-item-title-main">{token.device()}</span>
{token.isCurrent() && [
' — ',
<span className="AccessTokensList-item-title-sub">{app.translator.trans('core.forum.security.current_active_session')}</span>,
]}
</div>
);
} else {
items.add(
'title',
<div className="AccessTokensList-item-title">
<span className="AccessTokensList-item-title-main">{this.generateTokenTitle(token)}</span>
</div>
);
}
items.add(
'createdAt',
<div className="AccessTokensList-item-createdAt">
<LabelValue label={app.translator.trans('core.forum.security.created')} value={humanTime(token.createdAt())} />
</div>
);
items.add(
'lastActivityAt',
<div className="AccessTokensList-item-lastActivityAt">
<LabelValue
label={app.translator.trans('core.forum.security.last_activity')}
value={
token.lastActivityAt() ? (
<>
{humanTime(token.lastActivityAt())}
{token.lastIpAddress() && `${token.lastIpAddress()}`}
{this.attrs.type === 'developer_token' && token.device() && (
<>
{' '}
<span className="AccessTokensList-item-title-sub">{token.device()}</span>
</>
)}
</>
) : (
app.translator.trans('core.forum.security.never')
)
}
/>
</div>
);
return items;
}
tokenActionItems(token: AccessToken) {
const items = new ItemList<Mithril.Children>();
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',
<Button
className="Button Button--inverted"
icon={isHidden ? 'fas fa-eye' : 'fas fa-eye-slash'}
onclick={() => {
this.showingTokens[token.id()!] = isHidden;
m.redraw();
}}
>
{app.translator.trans(`core.forum.security.${displayKey}`)}
</Button>
);
}
let revokeButton = (
<Button className="Button Button--danger" disabled={token.isCurrent()} loading={!!this.loading[token.id()!]} onclick={() => this.revoke(token)}>
{app.translator.trans(`core.forum.security.${deleteKey}`)}
</Button>
);
if (token.isCurrent()) {
revokeButton = (
<Tooltip text={app.translator.trans('core.forum.security.cannot_terminate_current_session')}>
<div tabindex="0">{revokeButton}</div>
</Tooltip>
);
}
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 <code className="AccessTokensList-item-token">{value}</code>;
}
}

View File

@ -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<CustomAttrs extends INewAccessTokenModalAttrs = INewAccessTokenModalAttrs> extends Modal<CustomAttrs> {
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 (
<div className="Modal-body">
<div className="Form Form--centered">
<div className="Form-group">
<input type="text" className="FormControl" bidi={this.titleInput} placeholder={titleLabel} aria-label={titleLabel} />
</div>
<div className="Form-group">
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
{app.translator.trans('core.forum.security.new_access_token_modal.submit_button')}
</Button>
</div>
</div>
</div>
);
}
submitData(): SaveAttributes {
return {
title: this.titleInput(),
};
}
onsubmit(e: SubmitEvent) {
super.onsubmit(e);
e.preventDefault();
this.loading = true;
app.store
.createRecord<AccessToken>('access-tokens')
.save(this.submitData())
.then((token) => {
this.attrs.onsuccess(token);
app.modal.close();
})
.finally(this.loaded.bind(this));
}
}

View File

@ -131,6 +131,7 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
navItems() {
const items = new ItemList<Mithril.Children>();
const user = this.user!;
const isActor = app.session.user === user;
items.add(
'posts',
@ -148,7 +149,7 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
90
);
if (app.session.user === user) {
if (isActor) {
items.add('separator', <Separator />, -90);
items.add(
'settings',
@ -159,6 +160,20 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
);
}
if (isActor || app.forum.attribute<boolean>('canModerateAccessTokens')) {
if (!isActor) {
items.add('security-separator', <Separator />, -90);
}
items.add(
'security',
<LinkButton href={app.route('user.security', { username: user.slug() })} icon="fas fa-shield-alt">
{app.translator.trans('core.forum.user.security_link')}
</LinkButton>,
-100
);
}
return items;
}
}

View File

@ -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<CustomAttrs extends IUserPageAttrs = IUserPageAttrs> extends UserPage<CustomAttrs, UserSecurityPageState> {
state = new UserSecurityPageState();
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
const routeUsername = m.route.param('username');
if (routeUsername !== app.session.user?.slug() && !app.forum.attribute<boolean>('canModerateAccessTokens')) {
m.route.set('/');
}
this.loadUser(routeUsername);
app.setTitle(extractText(app.translator.trans('core.forum.security.title')));
this.loadTokens();
}
content() {
return (
<div className="UserSecurityPage">
<ul>{listItems(this.settingsItems().toArray())}</ul>
</div>
);
}
/**
* Build an item list for the user's settings controls.
*/
settingsItems() {
const items = new ItemList<Mithril.Children>();
['developerTokens', 'sessions'].forEach((section) => {
const sectionName = `${section}Items` as 'developerTokensItems' | 'sessionsItems';
const sectionLocale = camelCaseToSnakeCase(section);
items.add(
section,
<FieldSet className={`Security-${section}`} label={app.translator.trans(`core.forum.security.${sectionLocale}_heading`)}>
{this[sectionName]().toArray()}
</FieldSet>
);
});
return items;
}
/**
* Build an item list for the user's access accessToken settings.
*/
developerTokensItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'accessTokenList',
!this.state.hasLoadedTokens() ? (
<LoadingIndicator />
) : (
<AccessTokensList
type="developer_token"
ondelete={(token: AccessToken) => {
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',
<Button
className="Button"
disabled={!app.forum.attribute<boolean>('canCreateAccessToken')}
onclick={() =>
app.modal.show(NewAccessTokenModal, {
onsuccess: (token: AccessToken) => {
this.state.pushToken(token);
m.redraw();
},
})
}
>
{app.translator.trans('core.forum.security.new_access_token_button')}
</Button>
);
}
return items;
}
/**
* Build an item list for the user's access accessToken settings.
*/
sessionsItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'sessionsList',
!this.state.hasLoadedTokens() ? (
<LoadingIndicator />
) : (
<AccessTokensList
type="session"
ondelete={(token: AccessToken) => {
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 = (
<Button className="Button" onclick={this.terminateAllOtherSessions.bind(this)} loading={this.state.isLoading()} disabled={isDisabled}>
{app.translator.trans('core.forum.security.terminate_all_other_sessions')}
</Button>
);
if (isDisabled) {
terminateAllOthersButton = (
<Tooltip text={app.translator.trans('core.forum.security.cannot_terminate_current_session')}>
<span tabindex="0">{terminateAllOthersButton}</span>
</Tooltip>
);
}
items.add('terminateAllOtherSessions', terminateAllOthersButton);
}
return items;
}
loadTokens() {
return app.store
.find<AccessToken[]>('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));
}
}

View File

@ -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 },
};
}

View File

@ -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());
}
}

View File

@ -164,6 +164,9 @@
display: none;
}
}
.Button--inverted {
.Button--color-auto('button-inverted');
}
.Button--danger {
.Button--color-auto('control-danger');
}

View File

@ -0,0 +1,8 @@
.LabelValue {
display: flex;
gap: 4px;
&-label {
font-weight: bold;
}
}

View File

@ -14,6 +14,7 @@
@import "Button";
@import "Checkbox";
@import "ColorInput";
@import "LabelValue";
@import "Dropdown";
@import "EditUserModal";
@import "Form";

View File

@ -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";

View File

@ -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%;
}
}
}
}

View File

@ -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 <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> 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

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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.
});
}
];

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\Event\DeveloperTokenCreated;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
/**
* Not to be confused with the CreateTokenController,
* this controller is used by the actor to manually create a developer type access token.
*/
class CreateAccessTokenController extends AbstractCreateController
{
public $serializer = AccessTokenSerializer::class;
/**
* @var Dispatcher
*/
protected $events;
/**
* @var Factory
*/
protected $validation;
public function __construct(Dispatcher $events, Factory $validation)
{
$this->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;
}
}

View File

@ -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
{
/**

View File

@ -0,0 +1,52 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ServerRequestInterface;
class DeleteAccessTokenController extends AbstractDeleteController
{
/**
* {@inheritdoc}
*/
protected function delete(ServerRequestInterface $request)
{
$actor = RequestUtil::getActor($request);
$id = Arr::get($request->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);
}
}

View File

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\Filter\AccessTokenFilterer;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListAccessTokensController extends AbstractListController
{
public $serializer = AccessTokenSerializer::class;
/**
* @var UrlGenerator
*/
protected $url;
/**
* @var AccessTokenFilterer
*/
protected $filterer;
public function __construct(UrlGenerator $url, AccessTokenFilterer $filterer)
{
$this->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();
}
}

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Http\RememberAccessToken;
use Flarum\Http\RequestUtil;
use Flarum\Http\SessionAccessToken;
use Illuminate\Database\Eloquent\Builder;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ServerRequestInterface;
class TerminateAllOtherSessionsController extends AbstractDeleteController
{
/**
* {@inheritdoc}
*/
protected function delete(ServerRequestInterface $request)
{
$actor = RequestUtil::getActor($request);
$actor->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);
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Serializer;
use Flarum\Http\AccessToken;
use Jenssegers\Agent\Agent;
use Symfony\Contracts\Translation\TranslatorInterface;
class AccessTokenSerializer extends AbstractSerializer
{
/**
* {@inheritdoc}
*/
protected $type = 'access-tokens';
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
*/
public function __construct(TranslatorInterface $translator)
{
$this->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;
}
}

View File

@ -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(''), '/'),
];

View File

@ -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

View File

@ -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,

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http\Access;
use Flarum\Http\AccessToken;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class AccessTokenPolicy extends AbstractPolicy
{
public function revoke(User $actor, AccessToken $token)
{
if ($token->user_id === $actor->id || $actor->hasPermission('moderateAccessTokens')) {
return $this->allow();
}
}
}

View File

@ -0,0 +1,29 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeAccessTokenVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->isGuest()) {
$query->whereRaw('FALSE');
} elseif (! $actor->hasPermission('moderateAccessTokens')) {
$query->where('user_id', $actor->id);
}
}
}

View File

@ -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 = [

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http\Event;
use Flarum\Http\AccessToken;
class DeveloperTokenCreated
{
/**
* @var AccessToken
*/
public $token;
public function __construct(AccessToken $token)
{
$this->token = $token;
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http\Filter;
use Flarum\Filter\AbstractFilterer;
use Flarum\Http\AccessToken;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class AccessTokenFilterer extends AbstractFilterer
{
protected function getQuery(User $actor): Builder
{
return AccessToken::query()->whereVisibleTo($actor);
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http\Filter;
use Flarum\Api\Controller\ListAccessTokensController;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
/**
* Filters an access tokens request by the related user.
*
* @see ListAccessTokensController
*/
class UserFilter implements FilterInterface
{
/**
* @inheritDoc
*/
public function getFilterKey(): string
{
return 'user';
}
/**
* @inheritDoc
*/
public function filter(FilterState $filterState, string $filterValue, bool $negate)
{
$filterState->getQuery()->where('user_id', $negate ? '!=' : '=', $filterValue);
}
}

View File

@ -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()

View File

@ -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

View File

@ -14,4 +14,6 @@ class SessionAccessToken extends AccessToken
public static $type = 'session';
protected static $lifetime = 60 * 60; // 1 hour
protected $hidden = ['token'];
}

View File

@ -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],

View File

@ -0,0 +1,116 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\access_tokens;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CreateTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->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]
];
}
}

View File

@ -0,0 +1,210 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\access_tokens;
use Carbon\Carbon;
use Flarum\Http\AccessToken;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\RememberAccessToken;
use Flarum\Http\SessionAccessToken;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class DeleteTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->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]],
];
}
}

View File

@ -0,0 +1,184 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\access_tokens;
use Carbon\Carbon;
use Flarum\Http\AccessToken;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Illuminate\Support\Arr;
class ListTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->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]],
];
}
}

View File

@ -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

View File

@ -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"