Include auth token and generate auth token

This commit is contained in:
shamoon 2023-11-23 00:15:18 -08:00
parent f948113c0f
commit f90bd4722c
12 changed files with 185 additions and 31 deletions

View File

@ -158,6 +158,10 @@ The REST api provides three different forms of authentication.
3. Token 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. Paperless also offers an endpoint to acquire authentication tokens.
POST a username and password as a form or json string to 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> 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 ## Searching for documents

View File

@ -27,6 +27,27 @@
</div> </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="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> <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@ -3,3 +3,7 @@
margin: 0 !important; // hack-ish, for animation margin: 0 !important; // hack-ish, for animation
} }
} }
.copied-badge {
right: 8em;
}

View File

@ -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 { ProfileEditDialogComponent } from './profile-edit-dialog.component'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
@ -6,9 +11,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import {
NgbAccordionModule, NgbAccordionModule,
NgbActiveModal, NgbActiveModal,
NgbModal,
NgbModalModule, NgbModalModule,
NgbModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientModule } from '@angular/common/http' import { HttpClientModule } from '@angular/common/http'
import { TextComponent } from '../input/text/text.component' import { TextComponent } from '../input/text/text.component'
@ -16,12 +19,14 @@ import { PasswordComponent } from '../input/password/password.component'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Clipboard } from '@angular/cdk/clipboard'
const profile = { const profile = {
email: 'foo@bar.com', email: 'foo@bar.com',
password: '*********', password: '*********',
first_name: 'foo', first_name: 'foo',
last_name: 'bar', last_name: 'bar',
auth_token: '123456789abcdef',
} }
describe('ProfileEditDialogComponent', () => { describe('ProfileEditDialogComponent', () => {
@ -29,6 +34,7 @@ describe('ProfileEditDialogComponent', () => {
let fixture: ComponentFixture<ProfileEditDialogComponent> let fixture: ComponentFixture<ProfileEditDialogComponent>
let profileService: ProfileService let profileService: ProfileService
let toastService: ToastService let toastService: ToastService
let clipboard: Clipboard
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -48,6 +54,7 @@ describe('ProfileEditDialogComponent', () => {
}) })
profileService = TestBed.inject(ProfileService) profileService = TestBed.inject(ProfileService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent) fixture = TestBed.createComponent(ProfileEditDialogComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
@ -68,6 +75,7 @@ describe('ProfileEditDialogComponent', () => {
password: profile.password, password: profile.password,
first_name: 'foo2', first_name: 'foo2',
last_name: profile.last_name, last_name: profile.last_name,
auth_token: profile.auth_token,
} }
const updateSpy = jest.spyOn(profileService, 'update') const updateSpy = jest.spyOn(profileService, 'update')
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
@ -151,4 +159,39 @@ describe('ProfileEditDialogComponent', () => {
) )
expect(component.saveDisabled).toBeFalsy() 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)
})
}) })

View File

@ -4,6 +4,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { Clipboard } from '@angular/cdk/clipboard'
@Component({ @Component({
selector: 'pngx-profile-edit-dialog', selector: 'pngx-profile-edit-dialog',
@ -22,6 +23,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
password_confirm: new FormControl({ value: null, disabled: true }), password_confirm: new FormControl({ value: null, disabled: true }),
first_name: new FormControl(''), first_name: new FormControl(''),
last_name: new FormControl(''), last_name: new FormControl(''),
auth_token: new FormControl(''),
}) })
private currentPassword: string private currentPassword: string
@ -34,10 +36,13 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
private emailConfirm: string private emailConfirm: string
public showEmailConfirm: boolean = false public showEmailConfirm: boolean = false
public copied: boolean = false
constructor( constructor(
private profileService: ProfileService, private profileService: ProfileService,
public activeModal: NgbActiveModal, public activeModal: NgbActiveModal,
private toastService: ToastService private toastService: ToastService,
private clipboard: Clipboard
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@ -70,17 +75,17 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
return this.error?.password_confirm || this.error?.email_confirm return this.error?.password_confirm || this.error?.email_confirm
} }
onEmailKeyUp(event: KeyboardEvent) { onEmailKeyUp(event: KeyboardEvent): void {
this.newEmail = (event.target as HTMLInputElement)?.value this.newEmail = (event.target as HTMLInputElement)?.value
this.onEmailChange() this.onEmailChange()
} }
onEmailConfirmKeyUp(event: KeyboardEvent) { onEmailConfirmKeyUp(event: KeyboardEvent): void {
this.emailConfirm = (event.target as HTMLInputElement)?.value this.emailConfirm = (event.target as HTMLInputElement)?.value
this.onEmailChange() this.onEmailChange()
} }
onEmailChange() { onEmailChange(): void {
this.showEmailConfirm = this.currentEmail !== this.newEmail this.showEmailConfirm = this.currentEmail !== this.newEmail
if (this.showEmailConfirm) { if (this.showEmailConfirm) {
this.form.get('email_confirm').enable() 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.newPassword = (event.target as HTMLInputElement)?.value
this.onPasswordChange() this.onPasswordChange()
} }
onPasswordConfirmKeyUp(event: KeyboardEvent) { onPasswordConfirmKeyUp(event: KeyboardEvent): void {
this.passwordConfirm = (event.target as HTMLInputElement)?.value this.passwordConfirm = (event.target as HTMLInputElement)?.value
this.onPasswordChange() this.onPasswordChange()
} }
onPasswordChange() { onPasswordChange(): void {
this.showPasswordConfirm = this.currentPassword !== this.newPassword this.showPasswordConfirm = this.currentPassword !== this.newPassword
console.log(this.currentPassword, this.newPassword, this.passwordConfirm)
if (this.showPasswordConfirm) { if (this.showPasswordConfirm) {
this.form.get('password_confirm').enable() 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) const profile = Object.assign({}, this.form.value)
this.networkActive = true this.networkActive = true
this.profileService this.profileService
@ -142,7 +146,30 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
}) })
} }
cancel() { cancel(): void {
this.activeModal.close() 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)
}
} }

View File

@ -3,4 +3,5 @@ export interface PaperlessUserProfile {
password?: string password?: string
first_name?: string first_name?: string
last_name?: string last_name?: string
auth_token?: string
} }

View File

@ -43,4 +43,12 @@ describe('ProfileService', () => {
email: 'foo@bar.com', 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')
})
}) })

View File

@ -24,4 +24,11 @@ export class ProfileService {
profile profile
) )
} }
generateAuthToken(): Observable<string> {
return this.http.post<string>(
`${environment.apiBaseUrl}${this.endpoint}/generate_auth_token/`,
{}
)
}
} }

View File

@ -27,6 +27,7 @@ from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_perms from guardian.shortcuts import get_perms
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from whoosh.writing import AsyncWriter 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.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["email"], self.user.email) 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["first_name"], self.user.first_name)
self.assertEqual(response.data["last_name"], self.user.last_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.email, user_data["email"])
self.assertEqual(user.first_name, user_data["first_name"]) self.assertEqual(user.first_name, user_data["first_name"])
self.assertEqual(user.last_name, user_data["last_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)

View File

@ -101,7 +101,8 @@ class GroupSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer): class ProfileSerializer(serializers.ModelSerializer):
email = serializers.EmailField(allow_null=False) 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: class Meta:
model = User model = User
@ -110,4 +111,5 @@ class ProfileSerializer(serializers.ModelSerializer):
"password", "password",
"first_name", "first_name",
"last_name", "last_name",
"auth_token",
) )

View File

@ -35,6 +35,7 @@ from documents.views import UiSettingsView
from documents.views import UnifiedSearchViewSet from documents.views import UnifiedSearchViewSet
from paperless.consumers import StatusConsumer from paperless.consumers import StatusConsumer
from paperless.views import FaviconView from paperless.views import FaviconView
from paperless.views import GenerateAuthTokenView
from paperless.views import GroupViewSet from paperless.views import GroupViewSet
from paperless.views import ProfileView from paperless.views import ProfileView
from paperless.views import UserViewSet from paperless.views import UserViewSet
@ -120,6 +121,7 @@ urlpatterns = [
BulkEditObjectPermissionsView.as_view(), BulkEditObjectPermissionsView.as_view(),
name="bulk_edit_object_permissions", name="bulk_edit_object_permissions",
), ),
path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
re_path( re_path(
"^profile/", "^profile/",
ProfileView.as_view(), ProfileView.as_view(),

View File

@ -7,6 +7,7 @@ from django.db.models.functions import Lower
from django.http import HttpResponse from django.http import HttpResponse
from django.views.generic import View from django.views.generic import View
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authtoken.models import Token
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@ -111,20 +112,17 @@ class GroupViewSet(ModelViewSet):
class ProfileView(GenericAPIView): class ProfileView(GenericAPIView):
"""
User profile view, only available when logged in
"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = ProfileSerializer serializer_class = ProfileSerializer
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
user = self.request.user if hasattr(self.request, "user") else None user = self.request.user if hasattr(self.request, "user") else None
serializer = self.get_serializer(data=request.data)
return Response( return Response(serializer.to_representation(user))
{
"email": user.email,
"password": "**********",
"first_name": user.first_name,
"last_name": user.last_name,
},
)
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
@ -139,11 +137,25 @@ class ProfileView(GenericAPIView):
for key, value in serializer.validated_data.items(): for key, value in serializer.validated_data.items():
setattr(user, key, value) setattr(user, key, value)
user.save() 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( return Response(
{ token.key,
"email": user.email,
"password": "**********",
"first_name": user.first_name,
"last_name": user.last_name,
},
) )