Include auth token and generate auth token
This commit is contained in:
parent
f948113c0f
commit
f90bd4722c
@ -158,6 +158,10 @@ The REST api provides three different forms of authentication.
|
||||
|
||||
3. Token authentication
|
||||
|
||||
You can create (or re-create) an API token by opening the "My Profile"
|
||||
link in the user dropdown found in the web UI and clicking the circular
|
||||
arrow button.
|
||||
|
||||
Paperless also offers an endpoint to acquire authentication tokens.
|
||||
|
||||
POST a username and password as a form or json string to
|
||||
@ -169,7 +173,7 @@ The REST api provides three different forms of authentication.
|
||||
Authorization: Token <token>
|
||||
```
|
||||
|
||||
Tokens can be managed and revoked in the paperless admin.
|
||||
Tokens can also be managed in the Django admin.
|
||||
|
||||
## Searching for documents
|
||||
|
||||
|
@ -27,6 +27,27 @@
|
||||
</div>
|
||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>API Auth Token</label>
|
||||
<div class="position-relative">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use *ngIf="!copied" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" />
|
||||
<use *ngIf="copied" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" />
|
||||
</svg><span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-repeat" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||
</div>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
|
@ -3,3 +3,7 @@
|
||||
margin: 0 !important; // hack-ish, for animation
|
||||
}
|
||||
}
|
||||
|
||||
.copied-badge {
|
||||
right: 8em;
|
||||
}
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
|
||||
import { ProfileEditDialogComponent } from './profile-edit-dialog.component'
|
||||
import { ProfileService } from 'src/app/services/profile.service'
|
||||
@ -6,9 +11,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbAccordionModule,
|
||||
NgbActiveModal,
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { TextComponent } from '../input/text/text.component'
|
||||
@ -16,12 +19,14 @@ import { PasswordComponent } from '../input/password/password.component'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
|
||||
const profile = {
|
||||
email: 'foo@bar.com',
|
||||
password: '*********',
|
||||
first_name: 'foo',
|
||||
last_name: 'bar',
|
||||
auth_token: '123456789abcdef',
|
||||
}
|
||||
|
||||
describe('ProfileEditDialogComponent', () => {
|
||||
@ -29,6 +34,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
let fixture: ComponentFixture<ProfileEditDialogComponent>
|
||||
let profileService: ProfileService
|
||||
let toastService: ToastService
|
||||
let clipboard: Clipboard
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -48,6 +54,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
})
|
||||
profileService = TestBed.inject(ProfileService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
@ -68,6 +75,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
password: profile.password,
|
||||
first_name: 'foo2',
|
||||
last_name: profile.last_name,
|
||||
auth_token: profile.auth_token,
|
||||
}
|
||||
const updateSpy = jest.spyOn(profileService, 'update')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
@ -151,4 +159,39 @@ describe('ProfileEditDialogComponent', () => {
|
||||
)
|
||||
expect(component.saveDisabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support auth token copy', fakeAsync(() => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
component.ngOnInit()
|
||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||
component.copyAuthToken()
|
||||
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
|
||||
expect(component.copied).toBeTruthy()
|
||||
tick(3000)
|
||||
expect(component.copied).toBeFalsy()
|
||||
}))
|
||||
|
||||
it('should support generate token, display error if needed', () => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
|
||||
const generateSpy = jest.spyOn(profileService, 'generateAuthToken')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
generateSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('failed to generate'))
|
||||
)
|
||||
component.generateAuthToken()
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
|
||||
generateSpy.mockClear()
|
||||
const newToken = '789101112hijk'
|
||||
generateSpy.mockReturnValueOnce(of(newToken))
|
||||
component.generateAuthToken()
|
||||
expect(generateSpy).toHaveBeenCalled()
|
||||
expect(component.form.get('auth_token').value).not.toEqual(
|
||||
profile.auth_token
|
||||
)
|
||||
expect(component.form.get('auth_token').value).toEqual(newToken)
|
||||
})
|
||||
})
|
||||
|
@ -4,6 +4,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileService } from 'src/app/services/profile.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-profile-edit-dialog',
|
||||
@ -22,6 +23,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
password_confirm: new FormControl({ value: null, disabled: true }),
|
||||
first_name: new FormControl(''),
|
||||
last_name: new FormControl(''),
|
||||
auth_token: new FormControl(''),
|
||||
})
|
||||
|
||||
private currentPassword: string
|
||||
@ -34,10 +36,13 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
private emailConfirm: string
|
||||
public showEmailConfirm: boolean = false
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
constructor(
|
||||
private profileService: ProfileService,
|
||||
public activeModal: NgbActiveModal,
|
||||
private toastService: ToastService
|
||||
private toastService: ToastService,
|
||||
private clipboard: Clipboard
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -70,17 +75,17 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
return this.error?.password_confirm || this.error?.email_confirm
|
||||
}
|
||||
|
||||
onEmailKeyUp(event: KeyboardEvent) {
|
||||
onEmailKeyUp(event: KeyboardEvent): void {
|
||||
this.newEmail = (event.target as HTMLInputElement)?.value
|
||||
this.onEmailChange()
|
||||
}
|
||||
|
||||
onEmailConfirmKeyUp(event: KeyboardEvent) {
|
||||
onEmailConfirmKeyUp(event: KeyboardEvent): void {
|
||||
this.emailConfirm = (event.target as HTMLInputElement)?.value
|
||||
this.onEmailChange()
|
||||
}
|
||||
|
||||
onEmailChange() {
|
||||
onEmailChange(): void {
|
||||
this.showEmailConfirm = this.currentEmail !== this.newEmail
|
||||
if (this.showEmailConfirm) {
|
||||
this.form.get('email_confirm').enable()
|
||||
@ -96,19 +101,18 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onPasswordKeyUp(event: KeyboardEvent) {
|
||||
onPasswordKeyUp(event: KeyboardEvent): void {
|
||||
this.newPassword = (event.target as HTMLInputElement)?.value
|
||||
this.onPasswordChange()
|
||||
}
|
||||
|
||||
onPasswordConfirmKeyUp(event: KeyboardEvent) {
|
||||
onPasswordConfirmKeyUp(event: KeyboardEvent): void {
|
||||
this.passwordConfirm = (event.target as HTMLInputElement)?.value
|
||||
this.onPasswordChange()
|
||||
}
|
||||
|
||||
onPasswordChange() {
|
||||
onPasswordChange(): void {
|
||||
this.showPasswordConfirm = this.currentPassword !== this.newPassword
|
||||
console.log(this.currentPassword, this.newPassword, this.passwordConfirm)
|
||||
|
||||
if (this.showPasswordConfirm) {
|
||||
this.form.get('password_confirm').enable()
|
||||
@ -124,7 +128,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
save(): void {
|
||||
const profile = Object.assign({}, this.form.value)
|
||||
this.networkActive = true
|
||||
this.profileService
|
||||
@ -142,7 +146,30 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
cancel() {
|
||||
cancel(): void {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
generateAuthToken(): void {
|
||||
this.profileService.generateAuthToken().subscribe({
|
||||
next: (token: string) => {
|
||||
console.log(token)
|
||||
this.form.patchValue({ auth_token: token })
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error generating auth token`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
copyAuthToken(): void {
|
||||
this.clipboard.copy(this.form.get('auth_token').value)
|
||||
this.copied = true
|
||||
setTimeout(() => {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
@ -3,4 +3,5 @@ export interface PaperlessUserProfile {
|
||||
password?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
auth_token?: string
|
||||
}
|
||||
|
@ -43,4 +43,12 @@ describe('ProfileService', () => {
|
||||
email: 'foo@bar.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('supports generating new auth token', () => {
|
||||
service.generateAuthToken().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}profile/generate_auth_token/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
})
|
||||
|
@ -24,4 +24,11 @@ export class ProfileService {
|
||||
profile
|
||||
)
|
||||
}
|
||||
|
||||
generateAuthToken(): Observable<string> {
|
||||
return this.http.post<string>(
|
||||
`${environment.apiBaseUrl}${this.endpoint}/generate_auth_token/`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
@ -5823,7 +5824,6 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data["email"], self.user.email)
|
||||
self.assertEqual(response.data["password"], "**********")
|
||||
self.assertEqual(response.data["first_name"], self.user.first_name)
|
||||
self.assertEqual(response.data["last_name"], self.user.last_name)
|
||||
|
||||
@ -5852,3 +5852,26 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(user.email, user_data["email"])
|
||||
self.assertEqual(user.first_name, user_data["first_name"])
|
||||
self.assertEqual(user.last_name, user_data["last_name"])
|
||||
|
||||
def test_update_auth_token(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Configured user
|
||||
WHEN:
|
||||
- API call is made to generate auth token
|
||||
THEN:
|
||||
- Token is created the first time, updated the second
|
||||
"""
|
||||
|
||||
self.assertEqual(len(Token.objects.all()), 0)
|
||||
|
||||
response = self.client.post(f"{self.ENDPOINT}generate_auth_token/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
token1 = Token.objects.filter(user=self.user).first()
|
||||
self.assertIsNotNone(token1)
|
||||
|
||||
response = self.client.post(f"{self.ENDPOINT}generate_auth_token/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
token2 = Token.objects.filter(user=self.user).first()
|
||||
|
||||
self.assertNotEqual(token1.key, token2.key)
|
||||
|
@ -101,7 +101,8 @@ class GroupSerializer(serializers.ModelSerializer):
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer):
|
||||
email = serializers.EmailField(allow_null=False)
|
||||
password = ObfuscatedUserPasswordField(required=False)
|
||||
password = ObfuscatedUserPasswordField(required=False, allow_null=False)
|
||||
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@ -110,4 +111,5 @@ class ProfileSerializer(serializers.ModelSerializer):
|
||||
"password",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"auth_token",
|
||||
)
|
||||
|
@ -35,6 +35,7 @@ from documents.views import UiSettingsView
|
||||
from documents.views import UnifiedSearchViewSet
|
||||
from paperless.consumers import StatusConsumer
|
||||
from paperless.views import FaviconView
|
||||
from paperless.views import GenerateAuthTokenView
|
||||
from paperless.views import GroupViewSet
|
||||
from paperless.views import ProfileView
|
||||
from paperless.views import UserViewSet
|
||||
@ -120,6 +121,7 @@ urlpatterns = [
|
||||
BulkEditObjectPermissionsView.as_view(),
|
||||
name="bulk_edit_object_permissions",
|
||||
),
|
||||
path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
|
||||
re_path(
|
||||
"^profile/",
|
||||
ProfileView.as_view(),
|
||||
|
@ -7,6 +7,7 @@ from django.db.models.functions import Lower
|
||||
from django.http import HttpResponse
|
||||
from django.views.generic import View
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
@ -111,20 +112,17 @@ class GroupViewSet(ModelViewSet):
|
||||
|
||||
|
||||
class ProfileView(GenericAPIView):
|
||||
"""
|
||||
User profile view, only available when logged in
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ProfileSerializer
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
user = self.request.user if hasattr(self.request, "user") else None
|
||||
|
||||
return Response(
|
||||
{
|
||||
"email": user.email,
|
||||
"password": "**********",
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
},
|
||||
)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
return Response(serializer.to_representation(user))
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
@ -139,11 +137,25 @@ class ProfileView(GenericAPIView):
|
||||
for key, value in serializer.validated_data.items():
|
||||
setattr(user, key, value)
|
||||
user.save()
|
||||
|
||||
return Response(serializer.to_representation(user))
|
||||
|
||||
|
||||
class GenerateAuthTokenView(GenericAPIView):
|
||||
"""
|
||||
Generates (or re-generates) an auth token, requires a logged in user
|
||||
unlike the default DRF endpoint
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = self.request.user if hasattr(self.request, "user") else None
|
||||
|
||||
existing_token = Token.objects.filter(user=user).first()
|
||||
if existing_token is not None:
|
||||
existing_token.delete()
|
||||
token = Token.objects.create(user=user)
|
||||
return Response(
|
||||
{
|
||||
"email": user.email,
|
||||
"password": "**********",
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
},
|
||||
token.key,
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user