add better role indications

This commit is contained in:
rubikscraft 2022-03-23 20:40:31 +01:00
parent 860476ecd4
commit 6b1b973a4e
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
16 changed files with 144 additions and 41 deletions

View file

@ -47,7 +47,6 @@ services:
# PICSUR_DB_PASSWORD: picsur
# PICSUR_DB_NAME: picsur
# PICSUR_ADMIN_USERNAME: picsur
# PICSUR_ADMIN_PASSWORD: picsur
# PICSUR_JWT_SECRET: CHANGE_ME

View file

@ -1,6 +1,7 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HasFailed } from 'picsur-shared/dist/types';
import { generateRandomString } from 'picsur-shared/dist/util/random';
import { AuthConfigService } from '../../config/auth.config.service';
import { PicsurConfigModule } from '../../config/config.module';
import { EUserBackend } from '../../models/entities/user.entity';
@ -27,29 +28,49 @@ export class UsersModule implements OnModuleInit {
) {}
async onModuleInit() {
await this.ensureGuestExists();
await this.ensureAdminExists();
}
private async ensureAdminExists() {
const username = this.authConfigService.getDefaultAdminUsername();
const password = this.authConfigService.getDefaultAdminPassword();
this.logger.debug(`Ensuring admin user "${username}" exists`);
private async ensureGuestExists() {
const username = 'guest';
const password = generateRandomString(128);
this.logger.debug(`Ensuring guest user exists`);
const exists = await this.usersService.exists(username);
if (exists) return;
const newUser = await this.usersService.create(username, password);
const newUser = await this.usersService.create(
username,
password,
['guest'],
true,
);
if (HasFailed(newUser)) {
this.logger.error(
`Failed to create admin user "${username}" because: ${newUser.getReason()}`,
`Failed to create guest user because: ${newUser.getReason()}`,
);
return;
}
}
const result = await this.userRolesService.addRoles(newUser, ['admin']);
if (HasFailed(result)) {
private async ensureAdminExists() {
const username = 'admin';
const password = this.authConfigService.getDefaultAdminPassword();
this.logger.debug(`Ensuring admin user exists`);
const exists = await this.usersService.exists(username);
if (exists) return;
const newUser = await this.usersService.create(
username,
password,
['user', 'admin'],
true,
);
if (HasFailed(newUser)) {
this.logger.error(
`Failed to make admin user "${username}" because: ${result.getReason()}`,
`Failed to create admin user because: ${newUser.getReason()}`,
);
return;
}

View file

@ -2,7 +2,16 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { plainToClass } from 'class-transformer';
import { PermanentRolesList, Roles } from 'picsur-shared/dist/dto/roles.dto';
import {
DefaultRolesList,
PermanentRolesList,
Roles
} from 'picsur-shared/dist/dto/roles.dto';
import {
LockedLoginUsersList,
LockedPermsUsersList,
SystemUsersList
} from 'picsur-shared/dist/dto/specialusers.dto';
import {
AsyncFailable,
Fail,
@ -33,6 +42,7 @@ export class UsersService {
username: string,
password: string,
roles?: Roles,
byPassRoleCheck?: boolean,
): AsyncFailable<EUserBackend> {
if (await this.exists(username)) return Fail('User already exists');
@ -41,7 +51,13 @@ export class UsersService {
let user = new EUserBackend();
user.username = username;
user.password = hashedPassword;
user.roles = ['user', ...(roles || [])];
if (byPassRoleCheck) {
const rolesToAdd = roles ?? [];
user.roles = [...new Set([...rolesToAdd])];
} else {
const rolesToAdd = this.filterAddedRoles(roles ?? []);
user.roles = [...new Set([...DefaultRolesList, ...rolesToAdd])];
}
try {
user = await this.usersRepository.save(user, { reload: true });
@ -59,6 +75,10 @@ export class UsersService {
const userToModify = await this.resolve(user);
if (HasFailed(userToModify)) return userToModify;
if (SystemUsersList.includes(userToModify.username)) {
return Fail('Cannot delete system user');
}
try {
return await this.usersRepository.remove(userToModify);
} catch (e: any) {
@ -75,12 +95,15 @@ export class UsersService {
const userToModify = await this.resolve(user);
if (HasFailed(userToModify)) return userToModify;
if (LockedPermsUsersList.includes(userToModify.username)) {
// Just fail silently
return userToModify;
}
const rolesToKeep = userToModify.roles.filter((role) =>
PermanentRolesList.includes(role),
);
const rolesToAdd = roles.filter(
(role) => !PermanentRolesList.includes(role),
);
const rolesToAdd = this.filterAddedRoles(roles);
const newRoles = [...new Set([...rolesToKeep, ...rolesToAdd])];
@ -121,6 +144,10 @@ export class UsersService {
const user = await this.findOne(username, true);
if (HasFailed(user)) return user;
if (LockedLoginUsersList.includes(user.username)) {
return Fail('Wrong password');
}
if (!(await bcrypt.compare(password, user.password)))
return Fail('Wrong password');
@ -188,4 +215,12 @@ export class UsersService {
return user;
}
}
private filterAddedRoles(roles: Roles): Roles {
const filteredRoles = roles.filter(
(role) => !PermanentRolesList.includes(role),
);
return filteredRoles;
}
}

View file

@ -1,15 +1,12 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EnvPrefix } from './config.static';
@Injectable()
export class AuthConfigService {
constructor(private configService: ConfigService) {}
public getDefaultAdminPassword(): string {
return this.configService.get<string>('DEFAULT_ADMIN_PASSWORD', 'admin');
}
public getDefaultAdminUsername(): string {
return this.configService.get<string>('DEFAULT_ADMIN_USERNAME', 'admin');
return this.configService.get<string>(`${EnvPrefix}ADMIN_PASSWORD`, 'admin');
}
}

View file

@ -1,15 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EnvPrefix } from './config.static';
@Injectable()
export class EnvJwtConfigService {
constructor(private configService: ConfigService) {}
public getJwtSecret(): string | undefined {
return this.configService.get<string>('JWT_SECRET');
return this.configService.get<string>(`${EnvPrefix}JWT_SECRET`);
}
public getJwtExpiresIn(): string | undefined {
return this.configService.get<string>('JWT_EXPIRES_IN');
return this.configService.get<string>(`${EnvPrefix}JWT_EXPIRY`);
}
}

View file

@ -37,6 +37,6 @@ export class GuestStrategy extends PassportStrategy(
}
override async validate(payload: any) {
return this.guestService.createGuest();
return await this.guestService.getGuestUser();
}
}

View file

@ -1,13 +1,24 @@
import { Injectable } from '@nestjs/common';
import { HasFailed } from 'picsur-shared/dist/types';
import { UsersService } from '../../collections/userdb/userdb.service';
import { EUserBackend } from '../../models/entities/user.entity';
@Injectable()
export class GuestService {
public createGuest(): EUserBackend {
const guest = new EUserBackend();
guest.roles = ['guest'];
guest.username = 'guest';
private fallBackUser: EUserBackend;
return guest;
constructor(private usersService: UsersService) {
this.fallBackUser = new EUserBackend();
this.fallBackUser.roles = ['guest'];
this.fallBackUser.username = 'guest';
}
public async getGuestUser(): Promise<EUserBackend> {
const user = await this.usersService.findOne('guest');
if (HasFailed(user)) {
return this.fallBackUser;
}
return user;
}
}

View file

@ -49,7 +49,7 @@
</div>
</div>
<div class="row">
<div class="row" *ngIf="!isLockedPerms()">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Roles</mat-label>
@ -112,6 +112,10 @@
<button mat-raised-button color="accent" type="submit">
{{ editing ? "Update" : "Add" }}
</button>
<button mat-raised-button class="ms-2" color="primary" type="button" (click)="cancel()">
Cancel
</button>
</div>
</div>
</form>

View file

@ -4,6 +4,8 @@ import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { ActivatedRoute, Router } from '@angular/router';
import { UIFriendlyPermissions } from 'picsur-shared/dist/dto/permissions';
import { DefaultRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { LockedPermsUsersList } from 'picsur-shared/dist/dto/specialusers.dto';
import { HasFailed } from 'picsur-shared/dist/types';
import { UpdateUserControl } from 'src/app/models/forms/updateuser.control';
import { SnackBarType } from 'src/app/models/snack-bar-type';
@ -52,6 +54,7 @@ export class SettingsUsersEditComponent implements OnInit {
const username = this.route.snapshot.paramMap.get('username');
if (!username) {
this.mode = EditMode.add;
this.model.putRoles(DefaultRolesList);
return;
}
@ -97,6 +100,10 @@ export class SettingsUsersEditComponent implements OnInit {
this.model.addRole(event.option.viewValue);
}
cancel() {
this.router.navigate(['/settings/users']);
}
async updateUser() {
const data = this.model.getData();
@ -132,4 +139,12 @@ export class SettingsUsersEditComponent implements OnInit {
this.router.navigate(['/settings/users']);
}
isLockedPerms(): boolean {
if (this.adding) {
return false;
} else {
return LockedPermsUsersList.includes(this.model.getData().username);
}
}
}

View file

@ -10,6 +10,9 @@
<mat-header-cell *matHeaderCellDef>Roles</mat-header-cell>
<mat-cell *matCellDef="let user">
<mat-chip-list aria-label="User Roles">
<mat-chip [disableRipple]="true" [disabled]="true" *ngIf="isSystem(user)">
System
</mat-chip>
<mat-chip [disableRipple]="true" *ngFor="let role of user.roles">
{{ role }}
</mat-chip>
@ -23,7 +26,11 @@
<button mat-icon-button (click)="editUser(user)">
<mat-icon aria-label="Edit">edit</mat-icon>
</button>
<button mat-icon-button (click)="deleteUser(user)">
<button
*ngIf="!isSystem(user)"
mat-icon-button
(click)="deleteUser(user)"
>
<mat-icon class="icon-red" aria-label="Delete">delete</mat-icon>
</button>
</mat-cell>

View file

@ -7,5 +7,10 @@ mat-table {
}
.icon-red {
color: #F44336;
color: #f44336;
}
mat-chip-list {
margin-top: 8px;
margin-bottom: 8px;
}

View file

@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { Router } from '@angular/router';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { SystemUsersList } from 'picsur-shared/dist/dto/specialusers.dto';
import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { HasFailed } from 'picsur-shared/dist/types';
import { BehaviorSubject, Subject, throttleTime } from 'rxjs';
@ -16,11 +17,7 @@ import { DeleteConfirmDialogComponent } from './delete-confirm-dialog/delete-con
styleUrls: ['./settings-users.component.scss'],
})
export class SettingsUsersComponent implements OnInit {
public readonly displayedColumns: string[] = [
'username',
'roles',
'actions',
];
public readonly displayedColumns: string[] = ['username', 'roles', 'actions'];
public readonly pageSizeOptions: number[] = [5, 10, 25, 100];
public readonly startingPageSize = this.pageSizeOptions[2];
@ -104,4 +101,8 @@ export class SettingsUsersComponent implements OnInit {
return false;
}
isSystem(user: EUser): boolean {
return SystemUsersList.includes(user.username);
}
}

View file

@ -9,6 +9,8 @@ const PermanentRolesTuple = tuple('guest', 'user');
const ImmuteableRolesTuple = tuple('admin');
// These roles can never be removed from the server
const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple);
// These roles will be applied by default to new users
export const DefaultRolesList: string[] = ['user'];
// Derivatives
@ -16,6 +18,7 @@ export const PermanentRolesList: string[] = PermanentRolesTuple;
export const ImmuteableRolesList: string[] = ImmuteableRolesTuple;
export const SystemRolesList: string[] = SystemRolesTuple;
export type SystemRole = typeof SystemRolesTuple[number];
export type SystemRoles = SystemRole[];

View file

@ -0,0 +1,8 @@
// Cannot be deleted
export const SystemUsersList = ['guest', 'admin'];
// Cannot have different permissions
export const LockedPermsUsersList = ['admin'];
// Cannot login
export const LockedLoginUsersList = ['guest'];

View file

@ -1,8 +1,5 @@
import { Exclude } from 'class-transformer';
import {
IsArray, IsOptional,
IsString
} from 'class-validator';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { Roles } from '../dto/roles.dto';
import { EntityID } from '../validators/entity-id.validator';
import { IsPlainTextPwd, IsUsername } from '../validators/user.validators';

View file

@ -15,7 +15,6 @@ services:
# PICSUR_DB_PASSWORD: picsur
# PICSUR_DB_NAME: picsur
# PICSUR_ADMIN_USERNAME: picsur
# PICSUR_ADMIN_PASSWORD: picsur
# PICSUR_JWT_SECRET: CHANGE_ME