Csrf protection test: migrate to supertest
This commit is contained in:
parent
76811dd0b8
commit
84f2f7118a
@ -35,11 +35,13 @@
|
||||
"@types/nunjucks": "^3.1.3",
|
||||
"@types/on-finished": "^2.3.1",
|
||||
"@types/redis": "^2.8.18",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/ws": "^7.2.4",
|
||||
"jest": "^26.1.0",
|
||||
"maildev": "^1.1.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^26.1.1",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
|
@ -7,6 +7,7 @@ import compression from "compression";
|
||||
export default class ExpressAppComponent extends ApplicationComponent<void> {
|
||||
private readonly port: number;
|
||||
private server?: Server;
|
||||
private expressApp?: Express;
|
||||
|
||||
constructor(port: number) {
|
||||
super();
|
||||
@ -20,6 +21,8 @@ export default class ExpressAppComponent extends ApplicationComponent<void> {
|
||||
|
||||
// Proxy
|
||||
app.set('trust proxy', 'loopback');
|
||||
|
||||
this.expressApp = app;
|
||||
}
|
||||
|
||||
public async init(router: Router): Promise<void> {
|
||||
@ -48,4 +51,8 @@ export default class ExpressAppComponent extends ApplicationComponent<void> {
|
||||
if (!this.server) throw 'Server was not initialized.';
|
||||
return this.server;
|
||||
}
|
||||
|
||||
public getExpressApp(): Express {
|
||||
return this.expressApp!;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import fetch, {Response} from "node-fetch";
|
||||
import useApp, {DEFAULT_ADDR, TestApp} from "./_app";
|
||||
import useApp, {TestApp} from "./_app";
|
||||
import Controller from "../src/Controller";
|
||||
import CsrfProtectionComponent from "../src/components/CsrfProtectionComponent";
|
||||
import supertest from "supertest";
|
||||
|
||||
let app: TestApp;
|
||||
useApp(port => {
|
||||
return new class extends TestApp {
|
||||
return app = new class extends TestApp {
|
||||
protected async init(): Promise<void> {
|
||||
this.use(new class extends Controller {
|
||||
routes(): void {
|
||||
@ -32,86 +33,68 @@ useApp(port => {
|
||||
});
|
||||
|
||||
describe('Test CSRF protection', () => {
|
||||
let res: Response;
|
||||
let cookies: string[];
|
||||
let csrf: string;
|
||||
|
||||
test('no csrf token should be in session at first', async () => {
|
||||
res = await fetch(DEFAULT_ADDR, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain(`You weren't assigned any CSRF token.`);
|
||||
test('no csrf token should be in session at first', (done) => {
|
||||
const agent = supertest(app.getExpressApp());
|
||||
agent.post('/')
|
||||
.expect(401, (err, res) => {
|
||||
if (err) return done(err);
|
||||
|
||||
expect(res.text).toContain(`You weren't assigned any CSRF token.`);
|
||||
cookies = res.get('Set-Cookie');
|
||||
|
||||
agent.get('/')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200, (err, res) => {
|
||||
if (err) return done(err);
|
||||
|
||||
csrf = res.text;
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let cookies: string | null;
|
||||
|
||||
test('sending no csrf token should fail', async () => {
|
||||
cookies = res.headers.get('set-cookie');
|
||||
test('sending no csrf token should fail', (done) => {
|
||||
expect(cookies).toBeDefined();
|
||||
|
||||
res = await fetch(`${DEFAULT_ADDR}${Controller.route('csrf_test')}`, {
|
||||
headers: {
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const agent = supertest(app.getExpressApp());
|
||||
agent.post('/')
|
||||
.set('Cookie', cookies)
|
||||
.expect(401)
|
||||
.end((err, res) => {
|
||||
if (err) return done(err);
|
||||
|
||||
res = await fetch(DEFAULT_ADDR, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain(`You didn't provide any CSRF token.`);
|
||||
expect(res.text).toContain(`You didn't provide any CSRF token.`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('sending an invalid csrf token should fail', async () => {
|
||||
cookies = res.headers.get('set-cookie');
|
||||
test('sending an invalid csrf token should fail', (done) => {
|
||||
expect(cookies).toBeDefined();
|
||||
|
||||
res = await fetch(`${DEFAULT_ADDR}${Controller.route('csrf_test')}`, {
|
||||
headers: {
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const agent = supertest(app.getExpressApp());
|
||||
agent.post('/')
|
||||
.set('Cookie', cookies)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({csrf: 'not_a_valid_csrf'})
|
||||
.expect(401, (err, res) => {
|
||||
if (err) return done(err);
|
||||
|
||||
res = await fetch(DEFAULT_ADDR, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
csrf: 'not_a_valid_csrf',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain(`Tokens don't match.`);
|
||||
expect(res.text).toContain(`Tokens don't match.`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('sending a valid csrf token should success', async () => {
|
||||
cookies = res.headers.get('set-cookie');
|
||||
test('sending a valid csrf token should success', (done) => {
|
||||
expect(cookies).toBeDefined();
|
||||
|
||||
res = await fetch(`${DEFAULT_ADDR}${Controller.route('csrf_test')}`, {
|
||||
headers: {
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
let csrf = await res.text();
|
||||
|
||||
res = await fetch(DEFAULT_ADDR, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
csrf: csrf,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': cookies!,
|
||||
}
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const agent = supertest(app.getExpressApp());
|
||||
agent.post('/')
|
||||
.set('Cookie', cookies)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({csrf: csrf})
|
||||
.expect(200, done);
|
||||
});
|
||||
});
|
@ -2,7 +2,7 @@ import MysqlConnectionManager from "../src/db/MysqlConnectionManager";
|
||||
import Model from "../src/db/Model";
|
||||
import {MIGRATIONS} from "./_migrations";
|
||||
import ModelFactory from "../src/db/ModelFactory";
|
||||
import {ValidationBag, ValidationError} from "../src/db/Validator";
|
||||
import {ValidationBag} from "../src/db/Validator";
|
||||
|
||||
class FakeDummyModel extends Model {
|
||||
public id?: number = undefined;
|
||||
|
10
test/_app.ts
10
test/_app.ts
@ -16,6 +16,7 @@ import MagicLink from "../src/auth/models/MagicLink";
|
||||
import FormHelperComponent from "../src/components/FormHelperComponent";
|
||||
import RedirectBackComponent from "../src/components/RedirectBackComponent";
|
||||
import ServeStaticDirectoryComponent from "../src/components/ServeStaticDirectoryComponent";
|
||||
import {Express} from "express";
|
||||
|
||||
export default function useApp(appSupplier?: (port: number) => TestApp) {
|
||||
let app: Application;
|
||||
@ -37,6 +38,7 @@ export default function useApp(appSupplier?: (port: number) => TestApp) {
|
||||
|
||||
export class TestApp extends Application {
|
||||
private readonly port: number;
|
||||
private expressAppComponent?: ExpressAppComponent;
|
||||
|
||||
constructor(port: number) {
|
||||
super(require('../package.json').version, true);
|
||||
@ -54,12 +56,12 @@ export class TestApp extends Application {
|
||||
}
|
||||
|
||||
protected registerComponents() {
|
||||
const expressAppComponent = new ExpressAppComponent(this.port);
|
||||
this.expressAppComponent = new ExpressAppComponent(this.port);
|
||||
const redisComponent = new RedisComponent();
|
||||
const mysqlComponent = new MysqlComponent();
|
||||
|
||||
// Base
|
||||
this.use(expressAppComponent);
|
||||
this.use(this.expressAppComponent);
|
||||
this.use(new LogRequestsComponent());
|
||||
|
||||
// Static files
|
||||
@ -93,6 +95,10 @@ export class TestApp extends Application {
|
||||
|
||||
protected registerControllers() {
|
||||
}
|
||||
|
||||
public getExpressApp(): Express {
|
||||
return this.expressAppComponent!.getExpressApp();
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_ADDR = 'http://localhost:8966';
|
77
yarn.lock
77
yarn.lock
@ -585,6 +585,11 @@
|
||||
resolved "https://registry.toot.party/@types%2fcookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108"
|
||||
integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==
|
||||
|
||||
"@types/cookiejar@*":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.toot.party/@types%2fcookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80"
|
||||
integrity sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==
|
||||
|
||||
"@types/events@*":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.toot.party/@types%2fevents/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
|
||||
@ -761,6 +766,21 @@
|
||||
resolved "https://registry.toot.party/@types%2fstack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
|
||||
|
||||
"@types/superagent@*":
|
||||
version "4.1.8"
|
||||
resolved "https://registry.toot.party/@types%2fsuperagent/-/superagent-4.1.8.tgz#f663dcdd24705e07fce055003ace9b824f2a51c9"
|
||||
integrity sha512-iol9KxQ7SLHatBJUiZ4uABrS4VS1frLjqPednxZz82eoCzo3Uy3TOH0p0ZIBbfBj8E/xqOtvizjBs9h7xi/l2g==
|
||||
dependencies:
|
||||
"@types/cookiejar" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/supertest@^2.0.10":
|
||||
version "2.0.10"
|
||||
resolved "https://registry.toot.party/@types%2fsupertest/-/supertest-2.0.10.tgz#630d79b4d82c73e043e43ff777a9ca98d457cab7"
|
||||
integrity sha512-Xt8TbEyZTnD5Xulw95GLMOkmjGICrOQyJ2jqgkSjAUR3mm7pAIzSR0NFBaMcwlzVvlpCjNwbATcWWwjNiZiFrQ==
|
||||
dependencies:
|
||||
"@types/superagent" "*"
|
||||
|
||||
"@types/uuid@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.toot.party/@types%2fuuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
|
||||
@ -1509,7 +1529,7 @@ component-emitter@1.2.1:
|
||||
resolved "https://registry.toot.party/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
||||
integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
|
||||
|
||||
component-emitter@^1.2.1:
|
||||
component-emitter@^1.2.0, component-emitter@^1.2.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.toot.party/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
|
||||
integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
|
||||
@ -1621,6 +1641,11 @@ cookie@^0.4.1:
|
||||
resolved "https://registry.toot.party/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
||||
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
|
||||
|
||||
cookiejar@^2.1.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.toot.party/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c"
|
||||
integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==
|
||||
|
||||
copy-descriptor@^0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.toot.party/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
|
||||
@ -1727,7 +1752,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3:
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@^3.2.6:
|
||||
debug@^3.1.0, debug@^3.2.6:
|
||||
version "3.2.6"
|
||||
resolved "https://registry.toot.party/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
|
||||
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
|
||||
@ -2204,7 +2229,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
|
||||
assign-symbols "^1.0.0"
|
||||
is-extendable "^1.0.1"
|
||||
|
||||
extend@~3.0.0, extend@~3.0.2:
|
||||
extend@^3.0.0, extend@~3.0.0, extend@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.toot.party/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
@ -2317,6 +2342,15 @@ forever-agent@~0.6.1:
|
||||
resolved "https://registry.toot.party/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
|
||||
|
||||
form-data@^2.3.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.toot.party/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
|
||||
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.toot.party/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
|
||||
@ -2335,7 +2369,7 @@ form-data@~2.3.2:
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
formidable@^1.2.2:
|
||||
formidable@^1.2.0, formidable@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.toot.party/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9"
|
||||
integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==
|
||||
@ -3715,7 +3749,7 @@ merge-stream@^2.0.0:
|
||||
resolved "https://registry.toot.party/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||
|
||||
methods@~1.1.2:
|
||||
methods@^1.1.1, methods@^1.1.2, methods@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.toot.party/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
||||
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
|
||||
@ -3759,7 +3793,7 @@ mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
|
||||
dependencies:
|
||||
mime-db "1.44.0"
|
||||
|
||||
mime@1.6.0, mime@^1.6.0:
|
||||
mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.toot.party/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||
@ -4745,6 +4779,11 @@ qs@6.7.0:
|
||||
resolved "https://registry.toot.party/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
|
||||
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
|
||||
|
||||
qs@^6.5.1:
|
||||
version "6.9.4"
|
||||
resolved "https://registry.toot.party/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687"
|
||||
integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==
|
||||
|
||||
qs@~6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.toot.party/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
|
||||
@ -4804,7 +4843,7 @@ read-pkg@^5.2.0:
|
||||
parse-json "^5.0.0"
|
||||
type-fest "^0.6.0"
|
||||
|
||||
readable-stream@2.3.7, readable-stream@^2.0.6:
|
||||
readable-stream@2.3.7, readable-stream@^2.0.6, readable-stream@^2.3.5:
|
||||
version "2.3.7"
|
||||
resolved "https://registry.toot.party/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
|
||||
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
|
||||
@ -5494,6 +5533,30 @@ strip-json-comments@~2.0.1:
|
||||
resolved "https://registry.toot.party/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
|
||||
|
||||
superagent@^3.8.3:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.toot.party/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128"
|
||||
integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==
|
||||
dependencies:
|
||||
component-emitter "^1.2.0"
|
||||
cookiejar "^2.1.0"
|
||||
debug "^3.1.0"
|
||||
extend "^3.0.0"
|
||||
form-data "^2.3.1"
|
||||
formidable "^1.2.0"
|
||||
methods "^1.1.1"
|
||||
mime "^1.4.1"
|
||||
qs "^6.5.1"
|
||||
readable-stream "^2.3.5"
|
||||
|
||||
supertest@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.toot.party/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36"
|
||||
integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==
|
||||
dependencies:
|
||||
methods "^1.1.2"
|
||||
superagent "^3.8.3"
|
||||
|
||||
supports-color@^5.3.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.toot.party/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||
|
Loading…
Reference in New Issue
Block a user