forked from platypush/platypush
[#419] API tokens - frontend implementation.
This commit is contained in:
parent
a8343cb45b
commit
c13623c3f7
9 changed files with 647 additions and 348 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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 <token></code>
|
||||||
|
header (replace <code><token></code> with your token).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Specify it on the <code>X-Token <token></code> header (replace
|
||||||
|
<code><token></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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue