diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 9804bb9c08d133b74bfd08440f5d207314ded2cf..7dffebe94ba9e528006778a1ca924626449e8f15 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -280,8 +280,9 @@ JWT_AUTH = { 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7), 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30), 'JWT_AUTH_HEADER_PREFIX': 'JWT', + 'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key } - +OLD_PASSWORD_FIELD_ENABLED = True ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter' CORS_ORIGIN_ALLOW_ALL = True # CORS_ORIGIN_WHITELIST = ( diff --git a/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py new file mode 100644 index 0000000000000000000000000000000000000000..fd75795d3fae3b63cb1e5f8830d1aaec7acf2118 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0 on 2017-12-26 13:57 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_auto_20171214_2205'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='secret_key', + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + migrations.AlterField( + model_name='user', + name='last_name', + field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index c8d0b534c8b84706fcd050eee819e889e186f525..3a0baf11a30c73397b2c31d14fe1ec29d9557a7c 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import +import uuid + from django.contrib.auth.models import AbstractUser from django.urls import reverse from django.db import models @@ -15,6 +17,8 @@ class User(AbstractUser): # around the globe. name = models.CharField(_("Name of User"), blank=True, max_length=255) + # updated on logout or password change, to invalidate JWT + secret_key = models.UUIDField(default=uuid.uuid4, null=True) # permissions that are used for API access and that worth serializing relevant_permissions = { # internal_codename : {external_codename} @@ -31,3 +35,11 @@ class User(AbstractUser): def get_absolute_url(self): return reverse('users:detail', kwargs={'username': self.username}) + + def update_secret_key(self): + self.secret_key = uuid.uuid4() + return self.secret_key + + def set_password(self, raw_password): + super().set_password(raw_password) + self.update_secret_key() diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py index 9770e69e467cfb6ed377ff09ccbf6e81975be213..31f5384aa7f2a750bcaa4fc9063658876fbbd968 100644 --- a/api/funkwhale_api/users/rest_auth_urls.py +++ b/api/funkwhale_api/users/rest_auth_urls.py @@ -2,11 +2,15 @@ from django.views.generic import TemplateView from django.conf.urls import url from rest_auth.registration.views import VerifyEmailView +from rest_auth.views import PasswordChangeView + from .views import RegisterView + urlpatterns = [ url(r'^$', RegisterView.as_view(), name='rest_register'), url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'), + url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'), # This url is used by django-allauth and empty TemplateView is # defined just to allow reverse() call inside app, for example when email diff --git a/api/tests/users/test_jwt.py b/api/tests/users/test_jwt.py new file mode 100644 index 0000000000000000000000000000000000000000..d264494e59bfb06f358e7dd83225cf4eef187c0f --- /dev/null +++ b/api/tests/users/test_jwt.py @@ -0,0 +1,27 @@ +import pytest +import uuid + +from jwt.exceptions import DecodeError +from rest_framework_jwt.settings import api_settings + +from funkwhale_api.users.models import User + +def test_can_invalidate_token_when_changing_user_secret_key(factories): + user = factories['users.User']() + u1 = user.secret_key + jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER + jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + payload = jwt_payload_handler(user) + payload = jwt_encode_handler(payload) + + # this should work + api_settings.JWT_DECODE_HANDLER(payload) + + # now we update the secret key + user.update_secret_key() + user.save() + assert user.secret_key != u1 + + # token should be invalid + with pytest.raises(DecodeError): + api_settings.JWT_DECODE_HANDLER(payload) diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 5dbb24ac67eae64b0f484beb95a1523d1b7a0333..1eb8ef222a79d68f41d40c8555c0c7cb9d931680 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -97,3 +97,22 @@ def test_can_refresh_token_via_api(client, factories): assert '"token":' in response.content.decode('utf-8') # a different token should be returned assert token in response.content.decode('utf-8') + + +def test_changing_password_updates_secret_key(logged_in_client): + user = logged_in_client.user + password = user.password + secret_key = user.secret_key + payload = { + 'old_password': 'test', + 'new_password1': 'new', + 'new_password2': 'new', + } + url = reverse('change_password') + + response = logged_in_client.post(url, payload) + + user.refresh_from_db() + + assert user.secret_key != secret_key + assert user.password != password diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 607fa8ff2b84ffdde60ccbf3b514938dfeb5aba4..54af5a11c4c0027fc68f81e9c6eca283d4ed8aee 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -17,6 +17,10 @@ <i class="star icon"></i> Staff member </div> + <router-link class="ui tiny basic button" :to="{path: '/settings'}"> + <i class="setting icon"> </i>Settings... + </router-link> + </div> </template> </div> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue new file mode 100644 index 0000000000000000000000000000000000000000..d93373a1496937380eab3531855696289617996a --- /dev/null +++ b/front/src/components/auth/Settings.vue @@ -0,0 +1,84 @@ +<template> + <div class="main pusher"> + <div class="ui vertical stripe segment"> + <div class="ui small text container"> + <h2>Change my password</h2> + <form class="ui form" @submit.prevent="submit()"> + <div v-if="error" class="ui negative message"> + <div class="header">Cannot change your password</div> + <ul class="list"> + <li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li> + </ul> + </div> + <div class="field"> + <label>Old password</label> + <input + required + type="password" + autofocus + placeholder="Enter your old password" + v-model="old_password"> + </div> + <div class="field"> + <label>New password</label> + <input + required + type="password" + autofocus + placeholder="Enter your new password" + v-model="new_password"> + </div> + <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Change password</button> + </form> + </div> + </div> + </div> +</template> + +<script> +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +export default { + data () { + return { + // We need to initialize the component with any + // properties that will be used in it + old_password: '', + new_password: '', + error: '', + isLoading: false + } + }, + methods: { + submit () { + var self = this + self.isLoading = true + this.error = '' + var credentials = { + old_password: this.old_password, + new_password1: this.new_password, + new_password2: this.new_password + } + let resource = Vue.resource(config.BACKEND_URL + 'api/auth/registration/change-password/') + return resource.save({}, credentials).then(response => { + logger.default.info('Password successfully changed') + self.$router.push('/profile/me') + }, response => { + if (response.status === 400) { + self.error = 'invalid_credentials' + } else { + self.error = 'unknown_error' + } + self.isLoading = false + }) + } + } + +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/main.js b/front/src/main.js index 0c9230e8e8acc3059bf7bf030f53e74e5a69e204..f7a6b65f4df7cdd2ddfc4b37914999b12b3e9186 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -31,6 +31,7 @@ Vue.http.interceptors.push(function (request, next) { next(function (response) { // redirect to login form when we get unauthorized response from server if (response.status === 401) { + store.commit('auth/authenticated', false) logger.default.warn('Received 401 response from API, redirecting to login form') router.push({name: 'login', query: {next: router.currentRoute.fullPath}}) } diff --git a/front/src/router/index.js b/front/src/router/index.js index c7c46a27543bcd7b586c1e2679a0a32af3ba4597..f4efc723f4abc2fb9dfdca2e062d433c09d4e91e 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -4,6 +4,7 @@ import PageNotFound from '@/components/PageNotFound' import Home from '@/components/Home' import Login from '@/components/auth/Login' import Profile from '@/components/auth/Profile' +import Settings from '@/components/auth/Settings' import Logout from '@/components/auth/Logout' import Library from '@/components/library/Library' import LibraryHome from '@/components/library/Home' @@ -39,6 +40,11 @@ export default new Router({ name: 'logout', component: Logout }, + { + path: '/settings', + name: 'settings', + component: Settings + }, { path: '/@:username', name: 'profile', diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 815b0f70893da6fb0543b72c420dfcd9a02effc6..d8bd197f33fabd1938fe65a5c71ca950314b663f 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -29,13 +29,24 @@ export default { }, authenticated: (state, value) => { state.authenticated = value + if (value === false) { + state.username = null + state.token = null + state.tokenData = null + state.profile = null + state.availablePermissions = {} + } }, username: (state, value) => { state.username = value }, token: (state, value) => { state.token = value - state.tokenData = jwtDecode(value) + if (value) { + state.tokenData = jwtDecode(value) + } else { + state.tokenData = {} + } }, permission: (state, {key, status}) => { state.availablePermissions[key] = status @@ -60,7 +71,6 @@ export default { }, logout ({commit}) { commit('authenticated', false) - commit('profile', null) logger.default.info('Log out, goodbye!') router.push({name: 'index'}) },