mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 11:16:39 +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"
|
"flarum/testing": "self.version"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-json": "*",
|
|
||||||
"php": ">=7.3",
|
"php": ">=7.3",
|
||||||
|
"ext-json": "*",
|
||||||
"components/font-awesome": "^5.14.0",
|
"components/font-awesome": "^5.14.0",
|
||||||
"composer/composer": "^2.0",
|
"composer/composer": "^2.0",
|
||||||
"dflydev/fig-cookies": "^3.0.0",
|
"dflydev/fig-cookies": "^3.0.0",
|
||||||
|
@ -110,6 +110,7 @@
|
||||||
"illuminate/validation": "^8.0",
|
"illuminate/validation": "^8.0",
|
||||||
"illuminate/view": "^8.0",
|
"illuminate/view": "^8.0",
|
||||||
"intervention/image": "2.5.* || ^2.6.1",
|
"intervention/image": "2.5.* || ^2.6.1",
|
||||||
|
"jenssegers/agent": "^2.6",
|
||||||
"laminas/laminas-diactoros": "^2.4.1",
|
"laminas/laminas-diactoros": "^2.4.1",
|
||||||
"laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0",
|
"laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0",
|
||||||
"laminas/laminas-stratigility": "^3.2.2",
|
"laminas/laminas-stratigility": "^3.2.2",
|
||||||
|
|
|
@ -60,6 +60,7 @@
|
||||||
"illuminate/validation": "^8.0",
|
"illuminate/validation": "^8.0",
|
||||||
"illuminate/view": "^8.0",
|
"illuminate/view": "^8.0",
|
||||||
"intervention/image": "2.5.* || ^2.6.1",
|
"intervention/image": "2.5.* || ^2.6.1",
|
||||||
|
"jenssegers/agent": "^2.6",
|
||||||
"laminas/laminas-diactoros": "^2.4.1",
|
"laminas/laminas-diactoros": "^2.4.1",
|
||||||
"laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0",
|
"laminas/laminas-httphandlerrunner": "^1.2.0 || ^2.3.0",
|
||||||
"laminas/laminas-stratigility": "^3.2.2",
|
"laminas/laminas-stratigility": "^3.2.2",
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"@types/mithril": "^2.0.8",
|
"@types/mithril": "^2.0.8",
|
||||||
"@types/punycode": "^2.1.0",
|
"@types/punycode": "^2.1.0",
|
||||||
"@types/textarea-caret": "^3.0.1",
|
"@types/textarea-caret": "^3.0.1",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"bundlewatch": "^0.3.2",
|
"bundlewatch": "^0.3.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"expose-loader": "^3.1.0",
|
"expose-loader": "^3.1.0",
|
||||||
|
|
|
@ -236,6 +236,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||||
90
|
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'));
|
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
@ -396,6 +406,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||||
60
|
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'));
|
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
|
|
@ -36,6 +36,7 @@ import Model, { SavedModelData } from './Model';
|
||||||
import fireApplicationError from './helpers/fireApplicationError';
|
import fireApplicationError from './helpers/fireApplicationError';
|
||||||
import IHistory from './IHistory';
|
import IHistory from './IHistory';
|
||||||
import IExtender from './extenders/IExtender';
|
import IExtender from './extenders/IExtender';
|
||||||
|
import AccessToken from './models/AccessToken';
|
||||||
|
|
||||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||||
|
|
||||||
|
@ -178,6 +179,7 @@ export default class Application {
|
||||||
* The app's data store.
|
* The app's data store.
|
||||||
*/
|
*/
|
||||||
store: Store = new Store({
|
store: Store = new Store({
|
||||||
|
'access-tokens': AccessToken,
|
||||||
forums: Forum,
|
forums: Forum,
|
||||||
users: User,
|
users: User,
|
||||||
discussions: Discussion,
|
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 {
|
export function ucfirst(string: string): string {
|
||||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
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() {
|
navItems() {
|
||||||
const items = new ItemList<Mithril.Children>();
|
const items = new ItemList<Mithril.Children>();
|
||||||
const user = this.user!;
|
const user = this.user!;
|
||||||
|
const isActor = app.session.user === user;
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'posts',
|
'posts',
|
||||||
|
@ -148,7 +149,7 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
|
||||||
90
|
90
|
||||||
);
|
);
|
||||||
|
|
||||||
if (app.session.user === user) {
|
if (isActor) {
|
||||||
items.add('separator', <Separator />, -90);
|
items.add('separator', <Separator />, -90);
|
||||||
items.add(
|
items.add(
|
||||||
'settings',
|
'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;
|
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 Discussion from '../common/models/Discussion';
|
||||||
import type Post from '../common/models/Post';
|
import type Post from '../common/models/Post';
|
||||||
import type User from '../common/models/User';
|
import type User from '../common/models/User';
|
||||||
|
import UserSecurityPage from './components/UserSecurityPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper functions to generate URLs to form pages.
|
* 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 },
|
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
|
||||||
|
|
||||||
settings: { path: '/settings', component: SettingsPage },
|
settings: { path: '/settings', component: SettingsPage },
|
||||||
|
'user.security': { path: '/u/:username/security', component: UserSecurityPage },
|
||||||
notifications: { path: '/notifications', component: NotificationsPage },
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.Button--inverted {
|
||||||
|
.Button--color-auto('button-inverted');
|
||||||
|
}
|
||||||
.Button--danger {
|
.Button--danger {
|
||||||
.Button--color-auto('control-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 "Button";
|
||||||
@import "Checkbox";
|
@import "Checkbox";
|
||||||
@import "ColorInput";
|
@import "ColorInput";
|
||||||
|
@import "LabelValue";
|
||||||
@import "Dropdown";
|
@import "Dropdown";
|
||||||
@import "EditUserModal";
|
@import "EditUserModal";
|
||||||
@import "Form";
|
@import "Form";
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
@import "forum/Post";
|
@import "forum/Post";
|
||||||
@import "forum/PostStream";
|
@import "forum/PostStream";
|
||||||
@import "forum/Scrubber";
|
@import "forum/Scrubber";
|
||||||
|
@import "forum/UserSecurityPage";
|
||||||
@import "forum/SettingsPage";
|
@import "forum/SettingsPage";
|
||||||
@import "forum/SignUpModal";
|
@import "forum/SignUpModal";
|
||||||
@import "forum/Slidable";
|
@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:
|
permissions:
|
||||||
allow_post_editing_label: Allow post editing
|
allow_post_editing_label: Allow post editing
|
||||||
allow_renaming_label: Allow renaming
|
allow_renaming_label: Allow renaming
|
||||||
|
create_access_token_label: Create access token
|
||||||
create_heading: Create
|
create_heading: Create
|
||||||
delete_discussions_forever_label: Delete discussions forever
|
delete_discussions_forever_label: Delete discussions forever
|
||||||
delete_discussions_label: Delete discussions
|
delete_discussions_label: Delete discussions
|
||||||
|
@ -203,6 +204,7 @@ core:
|
||||||
edit_users_groups_label: Edit user groups
|
edit_users_groups_label: Edit user groups
|
||||||
global_heading: Global
|
global_heading: Global
|
||||||
moderate_heading: Moderate
|
moderate_heading: Moderate
|
||||||
|
moderate_access_tokens_label: Moderate Access Tokens
|
||||||
new_group_button: New Group
|
new_group_button: New Group
|
||||||
participate_heading: Participate
|
participate_heading: Participate
|
||||||
post_without_throttle_label: Reply multiple times without waiting
|
post_without_throttle_label: Reply multiple times without waiting
|
||||||
|
@ -468,6 +470,36 @@ core:
|
||||||
discussions_heading: => core.ref.discussions
|
discussions_heading: => core.ref.discussions
|
||||||
users_heading: => core.ref.users
|
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.
|
# These translations are used in the Settings page.
|
||||||
settings:
|
settings:
|
||||||
account_heading: Account
|
account_heading: Account
|
||||||
|
@ -505,6 +537,7 @@ core:
|
||||||
posts_empty_text: It looks like there are no posts here.
|
posts_empty_text: It looks like there are no posts here.
|
||||||
posts_link: => core.ref.posts
|
posts_link: => core.ref.posts
|
||||||
posts_load_more_button: => core.ref.load_more
|
posts_load_more_button: => core.ref.load_more
|
||||||
|
security_link: => core.ref.security
|
||||||
settings_link: => core.ref.settings
|
settings_link: => core.ref.settings
|
||||||
|
|
||||||
# These translations are found on the user profile page (admin function).
|
# These translations are found on the user profile page (admin function).
|
||||||
|
@ -542,6 +575,10 @@ core:
|
||||||
dropdown:
|
dropdown:
|
||||||
toggle_dropdown_accessible_label: Toggle dropdown menu
|
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).
|
# These translations are used in the Edit User modal dialog (admin function).
|
||||||
edit_user:
|
edit_user:
|
||||||
activate_button: Activate User
|
activate_button: Activate User
|
||||||
|
@ -747,6 +784,7 @@ core:
|
||||||
edit_user: Edit User
|
edit_user: Edit User
|
||||||
email: Email
|
email: Email
|
||||||
extensions: Extensions
|
extensions: Extensions
|
||||||
|
generic_confirmation_message: "Are you sure you want to proceed? This action cannot be undone."
|
||||||
icon: Icon
|
icon: Icon
|
||||||
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
|
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
|
load_more: Load More
|
||||||
|
@ -754,6 +792,7 @@ core:
|
||||||
log_in: Log In
|
log_in: Log In
|
||||||
log_out: Log Out
|
log_out: Log Out
|
||||||
mark_all_as_read: Mark All as Read
|
mark_all_as_read: Mark All as Read
|
||||||
|
new_token: New Token
|
||||||
next_page: Next Page
|
next_page: Next Page
|
||||||
notifications: Notifications
|
notifications: Notifications
|
||||||
okay: OK # Referenced by flarum-tags.yml
|
okay: OK # Referenced by flarum-tags.yml
|
||||||
|
@ -765,8 +804,9 @@ core:
|
||||||
reply: Reply # Referenced by flarum-mentions.yml
|
reply: Reply # Referenced by flarum-mentions.yml
|
||||||
reset_your_password: Reset Your Password
|
reset_your_password: Reset Your Password
|
||||||
restore: Restore
|
restore: Restore
|
||||||
save_changes: Save Changes
|
save_changes: Save Changes
|
||||||
search_users: Search users # Referenced by flarum-suspend.yml, flarum-tags.yml
|
search_users: Search users # Referenced by flarum-suspend.yml, flarum-tags.yml
|
||||||
|
security: Security
|
||||||
settings: Settings
|
settings: Settings
|
||||||
sign_up: Sign Up
|
sign_up: Sign Up
|
||||||
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
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\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
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
|
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'),
|
'canViewForum' => $this->actor->can('viewForum'),
|
||||||
'canStartDiscussion' => $this->actor->can('startDiscussion'),
|
'canStartDiscussion' => $this->actor->can('startDiscussion'),
|
||||||
'canSearchUsers' => $this->actor->can('searchUsers'),
|
'canSearchUsers' => $this->actor->can('searchUsers'),
|
||||||
|
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
|
||||||
|
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
|
||||||
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
|
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,27 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||||
$route->toController(Controller\ShowForumController::class)
|
$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
|
// Retrieve authentication token
|
||||||
$map->post(
|
$map->post(
|
||||||
'/token',
|
'/token',
|
||||||
|
@ -26,6 +47,13 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||||
$route->toController(Controller\CreateTokenController::class)
|
$route->toController(Controller\CreateTokenController::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Terminate all other sessions
|
||||||
|
$map->delete(
|
||||||
|
'/sessions',
|
||||||
|
'sessions.delete',
|
||||||
|
$route->toController(Controller\TerminateAllOtherSessionsController::class)
|
||||||
|
);
|
||||||
|
|
||||||
// Send forgot password email
|
// Send forgot password email
|
||||||
$map->post(
|
$map->post(
|
||||||
'/forgot',
|
'/forgot',
|
||||||
|
@ -71,7 +99,7 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||||
$map->delete(
|
$map->delete(
|
||||||
'/users/{id}',
|
'/users/{id}',
|
||||||
'users.delete',
|
'users.delete',
|
||||||
$route->toController(Controller\DeleteUserController::class)
|
$route->toController(Controller\DeleteAccessTokenController::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Upload avatar
|
// Upload avatar
|
||||||
|
|
|
@ -15,6 +15,8 @@ use Flarum\Foundation\AbstractServiceProvider;
|
||||||
use Flarum\Foundation\ContainerUtil;
|
use Flarum\Foundation\ContainerUtil;
|
||||||
use Flarum\Group\Filter as GroupFilter;
|
use Flarum\Group\Filter as GroupFilter;
|
||||||
use Flarum\Group\Filter\GroupFilterer;
|
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 as PostFilter;
|
||||||
use Flarum\Post\Filter\PostFilterer;
|
use Flarum\Post\Filter\PostFilterer;
|
||||||
use Flarum\User\Filter\UserFilterer;
|
use Flarum\User\Filter\UserFilterer;
|
||||||
|
@ -33,6 +35,9 @@ class FilterServiceProvider extends AbstractServiceProvider
|
||||||
{
|
{
|
||||||
$this->container->singleton('flarum.filter.filters', function () {
|
$this->container->singleton('flarum.filter.filters', function () {
|
||||||
return [
|
return [
|
||||||
|
AccessTokenFilterer::class => [
|
||||||
|
HttpFilter\UserFilter::class,
|
||||||
|
],
|
||||||
DiscussionFilterer::class => [
|
DiscussionFilterer::class => [
|
||||||
DiscussionQuery\AuthorFilterGambit::class,
|
DiscussionQuery\AuthorFilterGambit::class,
|
||||||
DiscussionQuery\CreatedFilterGambit::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 Carbon\Carbon;
|
||||||
use Flarum\Database\AbstractModel;
|
use Flarum\Database\AbstractModel;
|
||||||
|
use Flarum\Database\ScopeVisibilityTrait;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
@ -31,6 +32,8 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||||
*/
|
*/
|
||||||
class AccessToken extends AbstractModel
|
class AccessToken extends AbstractModel
|
||||||
{
|
{
|
||||||
|
use ScopeVisibilityTrait;
|
||||||
|
|
||||||
protected $table = 'access_tokens';
|
protected $table = 'access_tokens';
|
||||||
|
|
||||||
protected $dates = [
|
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\IdWithTransliteratedSlugDriver;
|
||||||
use Flarum\Discussion\Utf8SlugDriver;
|
use Flarum\Discussion\Utf8SlugDriver;
|
||||||
use Flarum\Foundation\AbstractServiceProvider;
|
use Flarum\Foundation\AbstractServiceProvider;
|
||||||
|
use Flarum\Http\Access\ScopeAccessTokenVisibility;
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
use Flarum\User\IdSlugDriver;
|
use Flarum\User\IdSlugDriver;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
|
@ -74,6 +75,8 @@ class HttpServiceProvider extends AbstractServiceProvider
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
$this->setAccessTokenTypes();
|
$this->setAccessTokenTypes();
|
||||||
|
|
||||||
|
AccessToken::registerVisibilityScoper(new ScopeAccessTokenVisibility(), 'view');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setAccessTokenTypes()
|
protected function setAccessTokenTypes()
|
||||||
|
|
|
@ -15,6 +15,8 @@ class RememberAccessToken extends AccessToken
|
||||||
|
|
||||||
protected static $lifetime = 5 * 365 * 24 * 60 * 60; // 5 years
|
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.
|
* Just a helper method so we can re-use the lifetime value which is protected.
|
||||||
* @return int
|
* @return int
|
||||||
|
|
|
@ -14,4 +14,6 @@ class SessionAccessToken extends AccessToken
|
||||||
public static $type = 'session';
|
public static $type = 'session';
|
||||||
|
|
||||||
protected static $lifetime = 60 * 60; // 1 hour
|
protected static $lifetime = 60 * 60; // 1 hour
|
||||||
|
|
||||||
|
protected $hidden = ['token'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,8 @@ use Flarum\Foundation\AbstractServiceProvider;
|
||||||
use Flarum\Foundation\ContainerUtil;
|
use Flarum\Foundation\ContainerUtil;
|
||||||
use Flarum\Group\Access\GroupPolicy;
|
use Flarum\Group\Access\GroupPolicy;
|
||||||
use Flarum\Group\Group;
|
use Flarum\Group\Group;
|
||||||
|
use Flarum\Http\Access\AccessTokenPolicy;
|
||||||
|
use Flarum\Http\AccessToken;
|
||||||
use Flarum\Post\Access\PostPolicy;
|
use Flarum\Post\Access\PostPolicy;
|
||||||
use Flarum\Post\Post;
|
use Flarum\Post\Post;
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
|
@ -48,6 +50,7 @@ class UserServiceProvider extends AbstractServiceProvider
|
||||||
$this->container->singleton('flarum.policies', function () {
|
$this->container->singleton('flarum.policies', function () {
|
||||||
return [
|
return [
|
||||||
Access\AbstractPolicy::GLOBAL => [],
|
Access\AbstractPolicy::GLOBAL => [],
|
||||||
|
AccessToken::class => [AccessTokenPolicy::class],
|
||||||
Discussion::class => [DiscussionPolicy::class],
|
Discussion::class => [DiscussionPolicy::class],
|
||||||
Group::class => [GroupPolicy::class],
|
Group::class => [GroupPolicy::class],
|
||||||
Post::class => [PostPolicy::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'
|
'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
|
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"
|
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
|
||||||
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
|
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@*":
|
"@types/yargs-parser@*":
|
||||||
version "21.0.0"
|
version "21.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
|
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user