Remove No Authentication mode from Kavita (#1006)

* Moved the Server Settings out into a button on nav header

* Refactored Mange Users page to the new design (skeleton). Implemented skeleton code for Invite User.

* Hashed out more of the code, but need to move all the email code to a Kavita controlled API server due to password credentials.

* Cleaned up some warnings

* When no user exists for an api key in Plugin controller, throw 401.

* Hooked in the ability to check if the Kavita instance can be accessed externally so we can determine if the user can invite or not.

* Hooked up some logic if the user's server isn't accessible, then default to old flow

* Basic flow is working for confirm email. Needs validation, error handling, etc.

* Refactored Password validation to account service

* Cleaned up the code in confirm-email to work much better.

* Refactored the login page to have a container functionality, so we can reuse the styles on multiple pages (registration pages). Hooked up the code for confirm email.

* Messy code, but making progress. Refactored Register to be used only for first time user registration. Added a new register component to handle first time flow only.

* Invite works much better, still needs a bit of work for non-accessible server setup. Started work on underlying manage users page to meet new design.

* Changed (you) to a star to indicate who you're logged in as.

* Inviting a user is now working and tested fully.

* Removed the register member component as we now have invite and confirm components.

* Editing a user is now working. Username change and Role/Library access from within one screen. Email changing is on hold.

* Cleaned up code for edit user and disabled email field for now.

* Cleaned up the code to indicate changing a user's email is not possible.

* Implemented a migration for existing accounts so they can validate their emails and still login.

* Change url for email server

* Implemented the ability to resend an email confirmation code (or regenerate for non accessible servers). Fixed an overflow on the confirm dialog.

* Removed all code around disabling authentication. Users that were already disabled can look up their password on the wiki.
This commit is contained in:
Joseph Milazzo 2022-01-31 05:42:06 -08:00 committed by GitHub
parent 1a72c53711
commit c8de3fb097
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 117 additions and 273 deletions

View File

@ -191,14 +191,6 @@ namespace API.Controllers
"You are missing an email on your account. Please wait while we migrate your account.");
}
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableAuthentication && !isAdmin)
{
_logger.LogDebug("User {UserName} is logging in with authentication disabled", loginDto.Username);
loginDto.Password = AccountService.DefaultPassword;
}
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, false);

View File

@ -23,17 +23,15 @@ namespace API.Controllers
private readonly ILogger<SettingsController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ITaskScheduler _taskScheduler;
private readonly IAccountService _accountService;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
IAccountService accountService, IDirectoryService directoryService, IMapper mapper)
IDirectoryService directoryService, IMapper mapper)
{
_logger = logger;
_unitOfWork = unitOfWork;
_taskScheduler = taskScheduler;
_accountService = accountService;
_directoryService = directoryService;
_mapper = mapper;
}
@ -84,7 +82,6 @@ namespace API.Controllers
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
var updateAuthentication = false;
var updateBookmarks = false;
var originalBookmarkDirectory = _directoryService.BookmarkDirectory;
@ -163,13 +160,6 @@ namespace API.Controllers
}
if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableAuthentication + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
updateAuthentication = true;
}
if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
@ -191,21 +181,6 @@ namespace API.Controllers
{
await _unitOfWork.CommitAsync();
if (updateAuthentication)
{
var users = await _unitOfWork.UserRepository.GetNonAdminUsersAsync();
foreach (var user in users)
{
var errors = await _accountService.ChangeUserPassword(user, AccountService.DefaultPassword);
if (!errors.Any()) continue;
await _unitOfWork.RollbackAsync();
return BadRequest(errors);
}
_logger.LogInformation("Server authentication changed. Updated all non-admins to default password");
}
if (updateBookmarks)
{
_directoryService.ExistOrCreate(bookmarkDirectory);
@ -253,12 +228,5 @@ namespace API.Controllers
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableOpds);
}
[HttpGet("authentication-enabled")]
public async Task<ActionResult<bool>> GetAuthenticationEnabled()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableAuthentication);
}
}
}

View File

@ -47,21 +47,6 @@ namespace API.Controllers
}
[AllowAnonymous]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUserNames()
{
// This is only for disabled auth flow - being removed
var setting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (setting.EnableAuthentication)
{
return Unauthorized("This API cannot be used given your server's configuration");
}
var members = await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync();
return Ok(members.Select(m => m.Username));
}
[HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
@ -104,6 +89,5 @@ namespace API.Controllers
return BadRequest("There was an issue saving preferences.");
}
}
}

View File

@ -23,11 +23,6 @@ namespace API.DTOs.Settings
/// Enables OPDS connections to be made to the server.
/// </summary>
public bool EnableOpds { get; set; }
/// <summary>
/// Enables Authentication on the server. Defaults to true.
/// </summary>
public bool EnableAuthentication { get; set; }
/// <summary>
/// Base Url for the kavita. Requires restart to take effect.
/// </summary>

View File

@ -47,6 +47,7 @@ namespace API.Entities.Enums
/// <summary>
/// Is Authentication needed for non-admin accounts
/// </summary>
/// <remarks>Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect</remarks>
[Description("EnableAuthentication")]
EnableAuthentication = 8,
/// <summary>

View File

@ -36,9 +36,6 @@ namespace API.Helpers.Converters
case ServerSettingKey.EnableOpds:
destination.EnableOpds = bool.Parse(row.Value);
break;
case ServerSettingKey.EnableAuthentication:
destination.EnableAuthentication = bool.Parse(row.Value);
break;
case ServerSettingKey.BaseUrl:
destination.BaseUrl = row.Value;
break;

View File

@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
@ -6,7 +7,7 @@ using System.IO.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Comparators;
using API.Extensions;
using Microsoft.Extensions.Logging;
@ -681,7 +682,7 @@ namespace API.Services
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception)
catch (Exception ex)
{
ClearAndDeleteDirectory(directoryName);
return false;

View File

@ -6,7 +6,6 @@ export interface ServerSettings {
port: number;
allowStatCollection: boolean;
enableOpds: boolean;
enableAuthentication: boolean;
baseUrl: string;
bookmarksDirectory: string;
}

View File

@ -65,15 +65,6 @@
</div>
</div>
<div class="form-group">
<label for="authentication" aria-describedby="authentication-info">Authentication</label>
<p class="accent" id="authentication-info">By disabling authentication, all non-admin users will be able to login by just their username. No password will be required to authenticate.</p>
<div class="form-check">
<input id="authentication" type="checkbox" aria-label="User Authentication" class="form-check-input" formControlName="enableAuthentication">
<label for="authentication" class="form-check-label">Enable Authentication</label>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>

View File

@ -40,7 +40,6 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
this.settingsForm.addControl('enableAuthentication', new FormControl(this.serverSettings.enableAuthentication, [Validators.required]));
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required]));
});
}
@ -54,29 +53,16 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
this.settingsForm.get('enableAuthentication')?.setValue(this.serverSettings.enableAuthentication);
this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl);
}
async saveSettings() {
const modelSettings = this.settingsForm.value;
if (this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value === false) {
if (!await this.confirmService.confirm('Disabling Authentication opens your server up to unauthorized access and possible hacking. Are you sure you want to continue with this?')) {
return;
}
}
const informUserAfterAuthenticationEnabled = this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value && !this.serverSettings.enableAuthentication;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
if (informUserAfterAuthenticationEnabled) {
await this.confirmService.alert('You have just re-enabled authentication. All non-admin users have been re-assigned a password of "[k.2@RZ!mxCQkJzE". This is a publicly known password. Please change their users passwords or request them to.');
}
}, (err: any) => {
console.error('error: ', err);
});

View File

@ -40,10 +40,4 @@ export class SettingsService {
getOpdsEnabled() {
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
}
getAuthenticationEnabled() {
return this.http.get<string>(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'}).pipe(map((res: string) => {
return res === 'true';
}));
}
}

View File

@ -36,6 +36,7 @@ import { PersonRolePipe } from './person-role.pipe';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { AllSeriesComponent } from './all-series/all-series.component';
import { PublicationStatusPipe } from './publication-status.pipe';
import { RegistrationModule } from './registration/registration.module';
@NgModule({
@ -80,6 +81,7 @@ import { PublicationStatusPipe } from './publication-status.pipe';
CardsModule,
CollectionsModule,
ReadingListModule,
RegistrationModule,
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',

View File

@ -24,6 +24,9 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir
RegistrationRoutingModule,
NgbTooltipModule,
ReactiveFormsModule
],
exports: [
SplashContainerComponent
]
})
export class RegistrationModule { }

View File

@ -1,38 +1,24 @@
<div class="mx-auto login">
<ng-container *ngIf="isLoaded">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
<div class="row row-cols-4 row-cols-md-4 row-cols-sm-2 row-cols-xs-2">
<ng-container *ngFor="let member of memberNames">
<div class="col align-self-center card p-3 m-3" style="max-width: 12rem;">
<span tabindex="0" (click)="select(member)" a11y-click="13,32">
<div class="logo-container">
<h3 class="card-title text-center">{{member | sentenceCase}}</h3>
</div>
</span>
<div class="card-text" #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed[member]" (keyup.enter)="$event.stopPropagation()">
<div class="form-group" [ngStyle]="authDisabled ? {display: 'none'} : {}">
<label for="username--{{member}}">Username</label>
<input class="form-control" formControlName="username" id="username--{{member}}" type="text" [readonly]="authDisabled">
</div>
<div class="form-group">
<label for="password--{{member}}">Password</label>
<input class="form-control" formControlName="password" id="password--{{member}}" type="password" autofocus>
<div *ngIf="authDisabled" class="invalid-feedback">
Authentication is disabled. Only type password if this is an admin account.
</div>
</div>
<div class="float-right">
<button class="btn btn-primary alt" type="submit--{{member}}">Login</button>
</div>
<app-splash-container>
<ng-container title><h2>Login</h2></ng-container>
<ng-container body>
<ng-container *ngIf="isLoaded">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
<div class="card-text">
<div class="form-group" style="width:100%">
<label for="username">Username</label>
<input class="form-control" formControlName="username" id="username" type="text">
</div>
<div class="form-group" style="width:100%">
<label for="password">Password</label>
<input class="form-control" formControlName="password" id="password" type="password" autofocus>
</div>
<div class="float-right">
<button class="btn btn-secondary alt" type="submit">Login</button>
</div>
</div>
</ng-container>
</div>
</form>
</form>
</ng-container>
</ng-container>
</div>
</app-splash-container>

View File

@ -1,84 +1,89 @@
@use "../../theme/colors";
// @use "../../theme/colors";
.login {
display: flex;
align-items: center;
justify-content: center;
margin-top: -61px; // To offset the navbar
height: calc(100vh);
min-height: 289px;
position: relative;
width: 100vw;
max-width: 100vw;
// .login {
// display: flex;
// align-items: center;
// justify-content: center;
// margin-top: -61px; // To offset the navbar
// height: calc(100vh);
// min-height: 289px;
// position: relative;
// width: 100vw;
// max-width: 100vw;
&::before {
content: "";
background-image: url('../../assets/images/login-bg.jpg');
background-size: cover;
position: absolute;
top: 0;
right: 0;
bottom: 0;
opacity: 0.1;
width: 100%;
}
// &::before {
// content: "";
// background-image: url('../../assets/images/login-bg.jpg');
// background-size: cover;
// position: absolute;
// top: 0;
// right: 0;
// bottom: 0;
// opacity: 0.1;
// width: 100%;
// }
.logo-container {
.logo {
display:inline-block;
height: 50px;
}
}
// .logo-container {
// .logo {
// display:inline-block;
// height: 50px;
// }
// }
.row {
margin-top: 10vh;
}
// .row {
// margin-top: 10vh;
// }
.card {
background-color: colors.$primary-color;
color: #fff;
cursor: pointer;
min-width: 300px;
// .card {
// background-color: colors.$primary-color;
// color: #fff;
// cursor: pointer;
// min-width: 300px;
&:focus {
border: 2px solid white;
// &:focus {
// border: 2px solid white;
}
// }
.card-title {
font-family: 'Spartan', sans-serif;
font-weight: bold;
display: inline-block;
vertical-align: middle;
width: 280px;
}
// .card-title {
// font-family: 'Spartan', sans-serif;
// font-weight: bold;
// display: inline-block;
// vertical-align: middle;
// width: 280px;
// }
.card-text {
font-family: "EBGaramond", "Helvetica Neue", sans-serif;
}
// .card-text {
// font-family: "EBGaramond", "Helvetica Neue", sans-serif;
// }
.alt {
background-color: #424c72;
border-color: #444f75;
}
// .alt {
// background-color: #424c72;
// border-color: #444f75;
// }
.alt:hover {
background-color: #3b4466;
}
// .alt:hover {
// background-color: #3b4466;
// }
.alt:focus {
background-color: #343c59;
box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%);
}
}
}
// .alt:focus {
// background-color: #343c59;
// box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%);
// }
// }
// }
.invalid-feedback {
display: inline-block;
color: #343c59;
}
// .invalid-feedback {
// display: inline-block;
// color: #343c59;
// }
// input {
// background-color: #fff !important;
// color: black;
// }
input {
background-color: #fff !important;

View File

@ -3,7 +3,7 @@ import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { first, take } from 'rxjs/operators';
import { take } from 'rxjs/operators';
import { SettingsService } from '../admin/settings.service';
import { AddEmailToAccountMigrationModalComponent } from '../registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component';
import { User } from '../_models/user';
@ -11,6 +11,7 @@ import { AccountService } from '../_services/account.service';
import { MemberService } from '../_services/member.service';
import { NavService } from '../_services/nav.service';
// TODO: Move this into registration module
@Component({
selector: 'app-user-login',
templateUrl: './user-login.component.html',
@ -21,12 +22,11 @@ export class UserLoginComponent implements OnInit {
model: any = {username: '', password: ''};
loginForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required])
password: new FormControl('', [Validators.required])
});
memberNames: Array<string> = [];
isCollapsed: {[key: string]: boolean} = {};
authDisabled: boolean = false;
/**
* If there are no admins on the server, this will enable the registration to kick in.
*/
@ -36,7 +36,7 @@ export class UserLoginComponent implements OnInit {
*/
isLoaded: boolean = false;
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
private toastr: ToastrService, private navService: NavService, private settingsService: SettingsService, private modalService: NgbModal) { }
ngOnInit(): void {
@ -47,36 +47,17 @@ export class UserLoginComponent implements OnInit {
}
});
this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe((enabled: boolean) => {
// There is a bug where this is coming back as a string not a boolean.
this.authDisabled = !enabled;
if (this.authDisabled) {
this.loginForm.get('password')?.setValidators([]);
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists;
// This API is only useable on disabled authentication
this.memberService.getMemberNames().pipe(take(1)).subscribe(members => {
this.memberNames = members;
const isOnlyOne = this.memberNames.length === 1;
this.memberNames.forEach(name => this.isCollapsed[name] = !isOnlyOne);
this.firstTimeFlow = members.length === 0;
this.isLoaded = true;
});
} else {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists;
if (this.firstTimeFlow) {
this.router.navigateByUrl('registration/register');
return;
}
this.setupAuthenticatedLoginFlow();
this.isLoaded = true;
});
if (this.firstTimeFlow) {
this.router.navigateByUrl('registration/register');
return;
}
});
this.setupAuthenticatedLoginFlow();
this.isLoaded = true;
});
}
setupAuthenticatedLoginFlow() {
@ -92,11 +73,6 @@ export class UserLoginComponent implements OnInit {
onAdminCreated(user: User | null) {
if (user != null) {
this.firstTimeFlow = false;
if (this.authDisabled) {
this.isCollapsed[user.username] = true;
this.select(user.username);
this.memberNames.push(user.username);
}
} else {
this.toastr.error('There was an issue creating the new user. Please refresh and try again.');
}
@ -118,43 +94,14 @@ export class UserLoginComponent implements OnInit {
}
}, err => {
if (err.error === 'You are missing an email on your account. Please wait while we migrate your account.') {
// TODO: Implement this flow
const modalRef = this.modalService.open(AddEmailToAccountMigrationModalComponent, { scrollable: true, size: 'md' });
modalRef.componentInstance.username = this.model.username;
modalRef.componentInstance.username = this.model.username;
modalRef.closed.pipe(take(1)).subscribe(() => {
});
} else {
this.toastr.error(err.error);
}
});
// TODO: Move this into account service so it always happens
this.accountService.currentUser$
.pipe(first(x => (x !== null && x !== undefined && typeof x !== 'undefined')))
.subscribe(currentUser => {
this.navService.setDarkMode(currentUser.preferences.siteDarkMode);
});
}
select(member: string) {
// This is a special case
if (member === ' Login ' && !this.authDisabled) {
return;
}
this.loginForm.get('username')?.setValue(member);
this.isCollapsed[member] = !this.isCollapsed[member];
this.collapseAllButName(member);
// ?! Scroll to the newly opened element?
}
collapseAllButName(name: string) {
Object.keys(this.isCollapsed).forEach(key => {
if (key !== name) {
this.isCollapsed[key] = true;
}
});
}
}

View File

@ -194,7 +194,7 @@
<app-series-bookmarks></app-series-bookmarks>
</ng-container>
<ng-container *ngIf="tab.fragment === 'password'">
<ng-container *ngIf="isAuthenticationEnabled || isAdmin; else authDisabled">
<ng-container *ngIf="isAdmin">
<p>Change your Password</p>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
@ -227,9 +227,6 @@
</div>
</form>
</ng-container>
<ng-template #authDisabled>
<p class="text-warning">Authentication is disabled on this server. A password is not required to authenticate.</p>
</ng-template>
</ng-container>
<ng-container *ngIf="tab.fragment === 'clients'">
<p>All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.</p>

View File

@ -28,7 +28,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
passwordChangeForm: FormGroup = new FormGroup({});
user: User | undefined = undefined;
isAdmin: boolean = false;
isAuthenticationEnabled: boolean = true;
passwordsMatch = false;
resetPasswordErrors: string[] = [];
@ -78,9 +77,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsService.getOpdsEnabled().subscribe(res => {
this.opdsEnabled = res;
});
this.settingsService.getAuthenticationEnabled().subscribe(res => {
this.isAuthenticationEnabled = res;
});
}
ngOnInit(): void {