[#419] API tokens - frontend implementation.

This commit is contained in:
Fabio Manganiello 2024-07-26 21:59:14 +02:00
parent a8343cb45b
commit c13623c3f7
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 647 additions and 348 deletions

View file

@ -4,7 +4,7 @@
<Application v-if="selectedPanel === 'application'" /> <Application v-if="selectedPanel === 'application'" />
<Users :session-token="sessionToken" :current-user="currentUser" <Users :session-token="sessionToken" :current-user="currentUser"
v-if="selectedPanel === 'users' && currentUser" /> v-if="selectedPanel === 'users' && currentUser" />
<Token :session-token="sessionToken" :current-user="currentUser" <Tokens :current-user="currentUser"
v-else-if="selectedPanel === 'tokens' && currentUser" /> v-else-if="selectedPanel === 'tokens' && currentUser" />
</main> </main>
</div> </div>
@ -12,13 +12,13 @@
<script> <script>
import Application from "@/components/panels/Settings/Application"; import Application from "@/components/panels/Settings/Application";
import Token from "@/components/panels/Settings/Token"; import Tokens from "@/components/panels/Settings/Tokens/Index";
import Users from "@/components/panels/Settings/Users"; import Users from "@/components/panels/Settings/Users";
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
name: "Settings", name: "Settings",
components: {Application, Users, Token}, components: {Application, Users, Tokens},
mixins: [Utils], mixins: [Utils],
emits: ['change-page'], emits: ['change-page'],
@ -39,16 +39,9 @@ export default {
async refresh() { async refresh() {
this.sessionToken = this.getCookies()['session_token'] this.sessionToken = this.getCookies()['session_token']
this.currentUser = await this.request('user.get_user_by_session', {session_token: this.sessionToken}) this.currentUser = await this.request('user.get_user_by_session', {session_token: this.sessionToken})
}
}, },
watch: { updatePage() {
selectedPanel(value) {
this.setUrlArgs({page: value})
},
},
async mounted() {
const args = this.getUrlArgs() const args = this.getUrlArgs()
let page = null let page = null
if (args.page?.length) { if (args.page?.length) {
@ -58,13 +51,27 @@ export default {
} }
this.$emit('change-page', page) this.$emit('change-page', page)
},
},
watch: {
selectedPanel(value) {
this.setUrlArgs({page: value})
},
$route() {
this.updatePage()
},
},
async mounted() {
this.updatePage()
await this.refresh() await this.refresh()
this.setUrlArgs({page: this.selectedPanel})
} }
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
$header-height: 3em; $header-height: 3em;
.settings-container { .settings-container {
@ -90,19 +97,21 @@ $header-height: 3em;
} }
} }
@include until($tablet) {
main { main {
height: calc(100% - #{$header-height}); height: calc(100% - #{$header-height});
overflow: auto; overflow: auto;
} }
}
@include from($tablet) {
main {
height: 100%;
}
}
button { button {
background: none; background: none;
border: none;
&:hover {
border: none;
color: $default-hover-fg;
}
} }
form { form {

View file

@ -1,322 +0,0 @@
<template>
<div class="token-container">
<Loading v-if="loading" />
<Modal ref="tokenModal">
<div class="token-container">
<label>
This is your generated token. Treat it carefully and do not share it with untrusted parties.<br/>
Also, make sure to save it - it WILL NOT be displayed again.
</label>
<textarea class="token" v-text="token" @focus="onTokenSelect" />
</div>
</Modal>
<Modal ref="sessionTokenModal">
<div class="token-container">
<label>
This is your current session token.
It will be invalidated once you log out of the current session.
</label>
<textarea class="token" v-text="sessionToken" @focus="onTokenSelect" />
</div>
</Modal>
<div class="body">
<div class="description">
<p>
Platypush provides two types of tokens:
<ul>
<li>
<b>JWT tokens</b> are bearer-only, and they contain encrypted
authentication information.<br/>
They can be used as permanent or time-based tokens to
authenticate with the Platypush API.
</li>
<li>
<b>Session tokens</b> are randomly generated tokens stored on the
application database. A session token generated in this session
will expire when you log out of it.
</li>
</ul>
</p>
<p>Generate a JWT authentication token that can be used for API calls to the <code>/execute</code> endpoint.</p><br/>
<p>You can include the token in your requests in any of the following ways:</p>
<ul>
<li>Specify it on the <code>Authorization: Bearer</code> header;</li>
<li>Specify it on the <code>X-Token</code> header;</li>
<li>
Specify it as a URL parameter: <code>http://site:8008/execute?token=...</code>
for a JWT token and <code>...?session_token=...</code> for a
session token;
</li>
<li>Specify it on the body of your JSON request:
<code>{"type":"request", "action", "...", "token":"..."}</code> for
a JWT token, or <code>"session_token"</code> for a session token.
</li>
</ul>
<p>Confirm your credentials in order to generate a new JWT token.</p>
<p>
<i>Show session token</i> will instead show the token cookie associated
to the current session.
</p>
</div>
<div class="form-container">
<form @submit.prevent="generateToken" ref="generateTokenForm">
<label>
<span>Username</span>
<span>
<input type="text" name="username" :value="currentUser.username" disabled>
</span>
</label>
<label>
<span>Confirm password</span>
<span>
<input type="password" name="password">
</span>
</label>
<label>
<span>Token validity in days</span>
<span>
<input type="text" name="validityDays">
</span>
<span class="note">
Decimal values are also supported - e.g. <i>0.5</i> means half a
day (12 hours). An empty or zero value means that the token has
no expiry date.
</span>
</label>
<label>
<input type="submit" class="btn btn-primary" value="Generate JWT token">
</label>
<label>
<input type="button" class="btn btn-default" value="Show session token"
@click.stop="$refs.sessionTokenModal.show()">
</label>
</form>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import Modal from "@/components/Modal";
export default {
name: "Token",
components: {Modal, Loading},
mixins: [Utils],
props: {
currentUser: {
type: Object,
required: true,
},
sessionToken: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
token: null,
}
},
methods: {
async generateToken(event) {
const username = this.currentUser.username
const password = event.target.password.value
let validityDays = event.target.validityDays?.length ? parseInt(event.target.validityDays.value) : 0
if (!validityDays)
validityDays = null
this.loading = true
try {
this.token = (await axios.post('/auth', {
username: username,
password: password,
expiry_days: validityDays,
})).data.token
if (this.token?.length)
this.$refs.tokenModal.show()
} catch (e) {
console.error(e.toString())
this.notify({
text: e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
onTokenSelect(event) {
event.target.select()
document.execCommand('copy')
this.notify({
text: 'Token copied to clipboard',
image: {
iconClass: 'fa fa-check',
}
})
},
}
}
</script>
<style lang="scss">
.token-container {
width: 100%;
display: flex;
flex-direction: column;
margin-top: .15em;
label {
width: 100%;
}
.body {
background: $background-color;
display: flex;
.description {
text-align: left;
padding: 1em;
}
}
ul {
margin: 1em .5em;
li {
list-style: initial;
}
}
.form-container {
display: flex;
}
form {
max-width: 250pt;
.note {
display: block;
font-size: .75em;
margin: -.75em 0 2em 0;
}
span {
input {
width: 100%;
}
}
}
input[type=password] {
border-radius: 1em;
}
.modal {
.content {
width: 90%;
}
.body {
margin-top: 0;
}
}
.token-container {
label {
display: flex;
flex-direction: column;
span {
display: block;
width: 100%;
}
}
textarea {
width: 100%;
height: 10em;
margin-top: 1em;
border-radius: 1em;
border: none;
background: $active-glow-bg-1;
padding: 1em;
}
}
.btn {
border-radius: 1em;
}
}
@media screen and (max-width: calc(#{$desktop} - 1px)) {
.token-container {
.body {
flex-direction: column;
}
}
.form-container {
justify-content: center;
box-shadow: $border-shadow-top;
margin-top: -1em;
padding-top: 1em;
}
}
@media screen and (min-width: $desktop) {
.token-container {
justify-content: center;
align-items: center;
.description {
width: 50%;
}
.form-container {
width: 50%;
justify-content: right;
padding: 1em;
label {
text-align: left;
}
}
.body {
max-width: 650pt;
flex-direction: row;
justify-content: left;
margin-top: 1.5em;
border-radius: 1em;
border: $default-border-2;
}
}
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div class="token-container">
<Loading v-if="loading" />
<Modal ref="tokenModal">
<div class="token-container">
<label>
This is your generated token. Treat it carefully and do not share it with untrusted parties.<br/>
Also, make sure to save it - it WILL NOT be displayed again.
</label>
<textarea class="token" v-text="token" @focus="copyToClipboard($event.target.value)" />
</div>
</Modal>
<Modal title="Generate an API token"
ref="tokenParamsModal"
@open="$nextTick(() => $refs.password.focus())"
@close="$refs.generateTokenForm.reset()">
<div class="form-container">
<p>Confirm your credentials in order to generate a new API token.</p>
<form @submit.prevent="generateToken" ref="generateTokenForm">
<label>
<span>Confirm password</span>
<span>
<input type="password" name="password" ref="password" placeholder="Password">
</span>
</label>
<label>
<span>
A friendly name used to identify this token - such as <code>My
App</code> or <code>My Site</code>.
</span>
<span>
<input type="text" name="name" placeholder="Token name">
</span>
</label>
<label>
<span>Token validity in days</span>
<span>
<input type="text" name="validityDays" placeholder="Validity in days">
</span>
</label>
<span class="note">
Decimal values are also supported - e.g. <i>0.5</i> means half a
day (12 hours). An empty or zero value means that the token has
no expiry date.
</span>
<label>
<input type="submit" class="btn btn-primary" value="Generate API Token">
</label>
</form>
</div>
</Modal>
<div class="body">
<label class="generate-btn-container">
<button class="btn btn-primary" @click="$refs.tokenParamsModal.show()">
Generate API Token
</button>
</label>
<p>
<b>API tokens</b> are randomly generated tokens that are stored
encrypted on the server, and can be used to authenticate with the
Platypush API.
</p>
<p>
When compared to the
<a href="/#settings?page=tokens&type=jwt">JWT tokens</a>, API tokens
have the following advantages:
<ul>
<li>They can be revoked at any time by the user who generated
them, while JWT tokens can only be revoked by changing the
user's password.</li>
<li>Their payload is random and not generated from the user's
password, so even if an attacker gains access to the server's
encryption keys, they cannot impersonate the user.</li>
<li>They can be generated with a friendly name that can be used
to identify the token.</li>
</ul>
<Description />
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import Description from "./Description";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import Modal from "@/components/Modal";
export default {
name: "Token",
mixins: [Utils],
components: {
Description,
Loading,
Modal,
},
props: {
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
token: null,
}
},
methods: {
async generateToken(event) {
const username = this.currentUser.username
const password = event.target.password.value
const name = event.target.name.value
let validityDays = event.target.validityDays?.length ? parseInt(event.target.validityDays.value) : 0
if (!validityDays)
validityDays = null
this.loading = true
try {
this.token = (await axios.post('/auth?type=token', {
username: username,
password: password,
name: name,
expiry_days: validityDays,
})).data.token
if (this.token?.length)
this.$refs.tokenModal.show()
} catch (e) {
console.error(e.toString())
this.notify({
text: e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
}
}
</script>
<style lang="scss">
@import "style.scss";
</style>

View file

@ -0,0 +1,25 @@
<template>
<p>
You can use your token to authenticate calls to the <code>/execute</code> endpoint or the Websocket routes.<br/><br/>
You can include the token in your requests in any of the following ways:
<ul>
<li>
Specify it on the <code>Authorization: Bearer &lt;token&gt;</code>
header (replace <code>&lt;token&gt;</code> with your token).
</li>
<li>
Specify it on the <code>X-Token &lt;token&gt;</code> header (replace
<code>&lt;token&gt;</code> with your token).
</li>
<li>
Specify it as a URL parameter: <code>http://site:8008/execute?token=...</code>.
</li>
<li>
Specify it on the body of your JSON request:
<code>{"type":"request", "action", "...", "token":"..."}</code>.
</li>
</ul>
</p>
</template>

View file

@ -0,0 +1,128 @@
<template>
<div class="tokens-container">
<Loading v-if="loading" />
<div class="main" v-else>
<div class="header">
<div class="tabs-container">
<Tabs>
<Tab :selected="tokenType === 'api'"
@input="tokenType = 'api'">
API Tokens
</Tab>
<Tab :selected="tokenType === 'jwt'"
@input="tokenType = 'jwt'">
JWT Tokens
</Tab>
</Tabs>
</div>
</div>
<div class="body">
<JwtToken v-if="tokenType === 'jwt'"
:current-user="currentUser" />
<ApiToken v-else
:current-user="currentUser" />
</div>
</div>
</div>
</template>
<script>
import ApiToken from "./ApiToken"
import JwtToken from "./JwtToken"
import Loading from "@/components/Loading"
import Tab from "@/components/elements/Tab"
import Tabs from "@/components/elements/Tabs"
import Utils from "@/Utils"
export default {
mixins: [Utils],
components: {
ApiToken,
JwtToken,
Loading,
Tab,
Tabs,
},
props: {
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
token: null,
tokenType: null,
}
},
methods: {
refresh() {
const args = this.getUrlArgs()
this.$nextTick(() => {
this.tokenType = args.type?.length ? args.type : 'api'
})
},
},
watch: {
tokenType(value) {
this.setUrlArgs({type: value})
},
$route() {
this.refresh()
},
},
mounted() {
this.refresh()
},
unmounted() {
this.setUrlArgs({type: null})
},
}
</script>
<style lang="scss" scoped>
$header-height: 4em;
.tokens-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
.header {
width: 100%;
height: $header-height;
align-items: center;
}
.main {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: $header-height;
margin-top: -0.2em;
}
.body {
height: calc(100% - #{$header-height} - 0.2em);
overflow: auto;
}
}
</style>

View file

@ -0,0 +1,167 @@
<template>
<div class="token-container">
<Loading v-if="loading" />
<Modal ref="tokenModal">
<div class="token-container">
<label>
This is your generated token. Treat it carefully and do not share it with untrusted parties.<br/>
Also, make sure to save it - it WILL NOT be displayed again.
</label>
<textarea class="token" v-text="token" @focus="copyToClipboard($event.target.value)" />
</div>
</Modal>
<Modal title="Generate a JWT token"
ref="tokenParamsModal"
@open="$nextTick(() => $refs.password.focus())"
@close="$refs.generateTokenForm.reset()">
<div class="form-container">
<p>Confirm your credentials in order to generate a new JWT token.</p>
<form @submit.prevent="generateToken" ref="generateTokenForm">
<label>
<span>Confirm password</span>
<span>
<input type="password" name="password" ref="password" placeholder="Password">
</span>
</label>
<label>
<span>Token validity in days</span>
<span>
<input type="text" name="validityDays" placeholder="Validity in days">
</span>
</label>
<span class="note">
Decimal values are also supported - e.g. <i>0.5</i> means half a
day (12 hours). An empty or zero value means that the token has
no expiry date.
</span>
<label>
<input type="submit" class="btn btn-primary" value="Generate JWT Token">
</label>
</form>
</div>
</Modal>
<div class="body">
<label class="generate-btn-container">
<button class="btn btn-primary" @click="$refs.tokenParamsModal.show()">
Generate JWT Token
</button>
</label>
<p>
<b>JWT tokens</b> are bearer-only, and they contain encrypted
authentication information.
</p>
<p>
They can be used as permanent or time-based tokens to authenticate
with the Platypush API.
</p>
<p>
When compared to the standard
<a href="/#settings?page=tokens&type=api">API tokens</a>, JWT tokens
have the following pros:
<ul>
<li>They are not stored on the server, so compromising the server
does not necessarily compromise the tokens too.</li>
</ul>
And the following cons:
<ul>
<li>They are not revocable - once generated, they can be used
indefinitely until they expire.</li>
<li>The only way to revoke a JWT token is to change the user's
password. However, if a user changes their password, all the
JWT tokens generated with the old password will be
invalidated.</li>
<li>Their payload is the encrypted representation of the user's
credentials, but without any OTP information, so an attacker
gains access to the user's credentials and the server's
encryption keys they can impersonate the user indefinitely
bypassing 2FA.</li>
</ul>
For these reasons, it is recommended to use generic API tokens over JWT
tokens for most use cases.<br/><br/>
<Description />
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import Description from "./Description";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import Modal from "@/components/Modal";
export default {
name: "Token",
components: {
Description,
Loading,
Modal,
},
mixins: [Utils],
props: {
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
token: null,
}
},
methods: {
async generateToken(event) {
const username = this.currentUser.username
const password = event.target.password.value
let validityDays = event.target.validityDays?.length ? parseInt(event.target.validityDays.value) : 0
if (!validityDays)
validityDays = null
this.loading = true
try {
this.token = (await axios.post('/auth?type=jwt', {
username: username,
password: password,
expiry_days: validityDays,
})).data.token
if (this.token?.length)
this.$refs.tokenModal.show()
} catch (e) {
console.error(e.toString())
this.notify({
text: e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
}
}
</script>
<style lang="scss">
@import "style.scss";
</style>

View file

@ -0,0 +1,124 @@
.token-container {
width: 100%;
display: flex;
flex-direction: column;
margin-top: .15em;
label {
width: 100%;
}
.body {
background: $background-color;
display: flex;
flex-direction: column;
padding: 2em;
text-align: left;
}
ul {
margin: 1em .5em;
li {
list-style: initial;
}
}
.form-container {
display: flex;
flex-direction: column;
align-items: center;
label {
height: fit-content;
display: flex;
flex-direction: column;
margin-bottom: 1em;
}
}
form {
max-width: 30em;
.note {
display: block;
font-size: .75em;
margin: -.75em 0 2em 0;
}
span {
input {
width: 100%;
}
}
}
.generate-btn-container {
width: 100%;
display: flex;
justify-content: center;
cursor: pointer;
}
input[type=password] {
border-radius: 1em;
}
.modal {
.content {
width: 90%;
}
.body {
margin-top: 0;
}
}
.token-container {
label {
display: flex;
flex-direction: column;
span {
display: block;
width: 100%;
}
}
textarea {
width: 100%;
height: 10em;
margin-top: 1em;
border-radius: 1em;
border: none;
background: $active-glow-bg-1;
padding: 1em;
}
}
.btn {
border-radius: 1em;
}
}
@media screen and (max-width: calc(#{$desktop} - 1px)) {
.form-container {
justify-content: center;
box-shadow: $border-shadow-top;
}
}
@media screen and (min-width: $desktop) {
.token-container {
justify-content: center;
align-items: center;
.body {
max-width: 650pt;
justify-content: left;
margin-top: 1.5em;
border-radius: 1em;
border: $default-border-2;
}
}
}

View file

@ -10,6 +10,11 @@ button, .btn, .btn-default {
border: $selected-border; border: $selected-border;
} }
&:hover {
background: $hover-bg;
border: 1px solid $default-hover-fg;
}
.icon { .icon {
margin-right: .5em; margin-right: .5em;
} }

View file

@ -12,7 +12,8 @@ input[type=time],
input[type=datetime-local], input[type=datetime-local],
input[type=number] { input[type=number] {
border: $default-border-2; border: $default-border-2;
border-radius: .5em; border-radius: 1em;
margin: .5em 0;
padding: .25em; padding: .25em;
&:hover { &:hover {