mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 04:56:37 +08:00
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:
parent
ea7b270f47
commit
9342903d68
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
29
framework/core/js/src/common/components/LabelValue.tsx
Normal file
29
framework/core/js/src/common/components/LabelValue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
34
framework/core/js/src/common/models/AccessToken.ts
Normal file
34
framework/core/js/src/common/models/AccessToken.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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()}`);
|
||||
}
|
||||
|
|
197
framework/core/js/src/forum/components/AccessTokensList.tsx
Normal file
197
framework/core/js/src/forum/components/AccessTokensList.tsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
199
framework/core/js/src/forum/components/UserSecurityPage.tsx
Normal file
199
framework/core/js/src/forum/components/UserSecurityPage.tsx
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
57
framework/core/js/src/forum/states/UserSecurityPageState.ts
Normal file
57
framework/core/js/src/forum/states/UserSecurityPageState.ts
Normal 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());
|
||||
}
|
||||
}
|
|
@ -164,6 +164,9 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
.Button--inverted {
|
||||
.Button--color-auto('button-inverted');
|
||||
}
|
||||
.Button--danger {
|
||||
.Button--color-auto('control-danger');
|
||||
}
|
||||
|
|
8
framework/core/less/common/LabelValue.less
Normal file
8
framework/core/less/common/LabelValue.less
Normal file
|
@ -0,0 +1,8 @@
|
|||
.LabelValue {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
&-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
@import "Button";
|
||||
@import "Checkbox";
|
||||
@import "ColorInput";
|
||||
@import "LabelValue";
|
||||
@import "Dropdown";
|
||||
@import "EditUserModal";
|
||||
@import "Form";
|
||||
|
|
|
@ -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";
|
||||
|
|
91
framework/core/less/forum/UserSecurityPage.less
Normal file
91
framework/core/less/forum/UserSecurityPage.less
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
});
|
||||
}
|
||||
];
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
73
framework/core/src/Api/Serializer/AccessTokenSerializer.php
Normal file
73
framework/core/src/Api/Serializer/AccessTokenSerializer.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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(''), '/'),
|
||||
];
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
24
framework/core/src/Http/Access/AccessTokenPolicy.php
Normal file
24
framework/core/src/Http/Access/AccessTokenPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
|
|
25
framework/core/src/Http/Event/DeveloperTokenCreated.php
Normal file
25
framework/core/src/Http/Event/DeveloperTokenCreated.php
Normal 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;
|
||||
}
|
||||
}
|
23
framework/core/src/Http/Filter/AccessTokenFilterer.php
Normal file
23
framework/core/src/Http/Filter/AccessTokenFilterer.php
Normal 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);
|
||||
}
|
||||
}
|
38
framework/core/src/Http/Filter/UserFilter.php
Normal file
38
framework/core/src/Http/Filter/UserFilter.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -14,4 +14,6 @@ class SessionAccessToken extends AccessToken
|
|||
public static $type = 'session';
|
||||
|
||||
protected static $lifetime = 60 * 60; // 1 hour
|
||||
|
||||
protected $hidden = ['token'];
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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]
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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]],
|
||||
];
|
||||
}
|
||||
}
|
184
framework/core/tests/integration/api/access_tokens/ListTest.php
Normal file
184
framework/core/tests/integration/api/access_tokens/ListTest.php
Normal 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]],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user