Implement authentication on frontend

- Added error handling on login
- Added login support, fixed register
- Added logout support on backend and frontend
- Changed role to isAdmin
- Added permission checking to menu
This commit is contained in:
Norbi Peti 2021-11-19 18:19:45 +01:00
parent cfc3d48a7b
commit dbf093e72e
No known key found for this signature in database
GPG key ID: DBA4C4549A927E56
15 changed files with 322 additions and 210 deletions

View file

@ -1,194 +1,205 @@
import {Count, CountSchema, Filter, FilterExcludingWhere, repository, Where,} from '@loopback/repository';
import {del, get, getModelSchemaRef, param, patch, post, put, requestBody, response,} from '@loopback/rest';
import {del, get, getModelSchemaRef, param, patch, post, Request, requestBody, response, RestBindings,} from '@loopback/rest';
import {User} from '../models';
import {UserRepository} from '../repositories';
import {
TokenServiceBindings,
UserServiceBindings
TokenServiceBindings,
UserServiceBindings
} from '@loopback/authentication-jwt';
import {inject} from '@loopback/core';
import {TokenService} from '@loopback/authentication';
import {authenticate, TokenService} from '@loopback/authentication';
import {SecurityBindings, UserProfile} from '@loopback/security';
import {genSalt, hash} from 'bcryptjs';
import {SzakdolgozatUserService} from '../services';
export class UserController {
constructor(
@inject(TokenServiceBindings.TOKEN_SERVICE)
public jwtService: TokenService,
@inject(UserServiceBindings.USER_SERVICE)
public userService: SzakdolgozatUserService,
@inject(SecurityBindings.USER, {optional: true})
public user: UserProfile,
@repository(UserRepository)
public userRepository : UserRepository,
) { }
constructor(
@inject(TokenServiceBindings.TOKEN_SERVICE)
public jwtService: TokenService,
@inject(UserServiceBindings.USER_SERVICE)
public userService: SzakdolgozatUserService,
@inject(SecurityBindings.USER, {optional: true})
public user: UserProfile,
@repository(UserRepository)
public userRepository: UserRepository,
) {
}
@post('/users')
@response(200, {
description: 'User model instance',
content: {'application/json': {schema: getModelSchemaRef(User, {exclude: ['password']})}},
})
async register(
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {
title: 'Registration request',
exclude: ['id', 'role']
}),
},
},
@post('/users')
@response(200, {
description: 'User model instance',
content: {'application/json': {schema: getModelSchemaRef(User, {exclude: ['password']})}},
})
request: Pick<User, 'email' | 'name' | 'password'>,
): Promise<User> {
const password = await hash(request.password, await genSalt());
const user = {
email: request.email,
name: request.name,
password: password,
role: 'student'
} as User;
return this.userRepository.create(user);
}
@post('/users/login', {
responses: {
'200': {
description: 'Token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
token: {
type: 'string',
async register(
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {
title: 'Registration request',
exclude: ['id', 'isAdmin']
}),
},
},
},
},
},
},
},
})
async login(
@requestBody({
description: 'The input of login function',
required: true,
content: {
'application/json': {
schema: getModelSchemaRef(User, {exclude: ['id', 'role', 'name']})
},
}}) credentials: Pick<User, 'email' | 'password'>,
): Promise<{token: string}> {
// ensure the user exists, and the password is correct
const user = await this.userService.verifyCredentials(credentials);
// convert a User object into a UserProfile object (reduced set of properties)
const userProfile = this.userService.convertToUserProfile(user);
})
request: Pick<User, 'email' | 'name' | 'password'>,
): Promise<User> {
const password = await hash(request.password, await genSalt());
const user = {
email: request.email,
name: request.name,
password: password,
isAdmin: false
} as User;
return this.userRepository.create(user);
}
// create a JSON Web Token based on the user profile
const token = await this.jwtService.generateToken(userProfile);
return {token};
}
@get('/users/count')
@response(200, {
description: 'User model count',
content: {'application/json': {schema: CountSchema}},
})
async count(
@param.where(User) where?: Where<User>,
): Promise<Count> {
return this.userRepository.count(where);
}
@get('/users')
@response(200, {
description: 'Array of User model instances',
content: {
'application/json': {
schema: {
type: 'array',
items: getModelSchemaRef(User, {includeRelations: true}),
@post('/users/login', {
responses: {
'200': {
description: 'Token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
token: {
type: 'string',
},
user: getModelSchemaRef(User, {exclude: ['id', 'password']})
},
},
},
},
},
},
},
},
})
async find(
@param.filter(User) filter?: Filter<User>,
): Promise<User[]> {
return this.userRepository.find(filter);
}
@patch('/users')
@response(200, {
description: 'User PATCH success count',
content: {'application/json': {schema: CountSchema}},
})
async updateAll(
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {partial: true}),
},
},
})
user: User,
@param.where(User) where?: Where<User>,
): Promise<Count> {
return this.userRepository.updateAll(user, where);
}
async login(
@requestBody({
description: 'The input of login function',
required: true,
content: {
'application/json': {
schema: getModelSchemaRef(User, {exclude: ['id', 'isAdmin', 'name']})
},
}
}) credentials: Pick<User, 'email' | 'password'>,
): Promise<{ token: string, user: Omit<User, 'id' | 'password'> }> {
// ensure the user exists, and the password is correct
const user = await this.userService.verifyCredentials(credentials);
// convert a User object into a UserProfile object (reduced set of properties)
const userProfile = this.userService.convertToUserProfile(user);
@get('/users/{id}')
@response(200, {
description: 'User model instance',
content: {
'application/json': {
schema: getModelSchemaRef(User, {includeRelations: true}),
},
},
})
async findById(
@param.path.number('id') id: number,
@param.filter(User, {exclude: 'where'}) filter?: FilterExcludingWhere<User>
): Promise<User> {
return this.userRepository.findById(id, filter);
}
// create a JSON Web Token based on the user profile
const token = await this.jwtService.generateToken(userProfile);
return {token, user};
}
@patch('/users/{id}')
@response(204, {
description: 'User PATCH success',
})
async updateById(
@param.path.number('id') id: number,
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {partial: true}),
@post('/users/logout', {
responses: {
'204': {
description: 'Logged out',
},
},
},
})
user: User,
): Promise<void> {
await this.userRepository.updateById(id, user);
}
@authenticate('jwt')
async logout(@inject(RestBindings.Http.REQUEST) request: Request): Promise<void> {
const split = request.headers.authorization?.split(' ');
if (split && split.length > 1) {
if (this.jwtService.revokeToken) {
await this.jwtService.revokeToken(split[1]);
} else {
console.error('Cannot revoke token');
}
}
}
@put('/users/{id}')
@response(204, {
description: 'User PUT success',
})
async replaceById(
@param.path.number('id') id: number,
@requestBody() user: User,
): Promise<void> {
await this.userRepository.replaceById(id, user);
}
@get('/users/count')
@response(200, {
description: 'User model count',
content: {'application/json': {schema: CountSchema}},
})
async count(
@param.where(User) where?: Where<User>,
): Promise<Count> {
return this.userRepository.count(where);
}
@del('/users/{id}')
@response(204, {
description: 'User DELETE success',
})
async deleteById(@param.path.number('id') id: number): Promise<void> {
await this.userRepository.deleteById(id);
}
@get('/users')
@response(200, {
description: 'Array of User model instances',
content: {
'application/json': {
schema: {
type: 'array',
items: getModelSchemaRef(User, {includeRelations: true}),
},
},
},
})
async find(
@param.filter(User) filter?: Filter<User>,
): Promise<User[]> {
return this.userRepository.find(filter);
}
@patch('/users')
@response(200, {
description: 'User PATCH success count',
content: {'application/json': {schema: CountSchema}},
})
async updateAll(
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {partial: true}),
},
},
})
user: User,
@param.where(User) where?: Where<User>,
): Promise<Count> {
return this.userRepository.updateAll(user, where);
}
@get('/users/{id}')
@response(200, {
description: 'User model instance',
content: {
'application/json': {
schema: getModelSchemaRef(User, {includeRelations: true}),
},
},
})
async findById(
@param.path.number('id') id: number,
@param.filter(User, {exclude: 'where'}) filter?: FilterExcludingWhere<User>
): Promise<User> {
return this.userRepository.findById(id, filter);
}
@patch('/users/{id}')
@response(204, {
description: 'User PATCH success',
})
async updateById(
@param.path.number('id') id: number,
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {partial: true}),
},
},
})
user: User,
): Promise<void> {
await this.userRepository.updateById(id, user);
}
@del('/users/{id}')
@response(204, {
description: 'User DELETE success',
})
async deleteById(@param.path.number('id') id: number): Promise<void> {
await this.userRepository.deleteById(id);
}
}

View file

@ -39,10 +39,10 @@ export class User extends Entity {
password: string;
@property({
type: 'string',
type: 'boolean',
required: true,
})
role: 'admin' | 'teacher' | 'student';
isAdmin: boolean;
constructor(data?: Partial<User>) {

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api.service';
describe('ApiService', () => {
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,26 @@
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../environments/environment';
import {LoginService} from './shared/login.service';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(private http: HttpClient, private loginService: LoginService) {
}
request(method: 'post' | 'get' | 'delete', url: string, body: any): Promise<any> {
return this.http.request(method, environment.backendUrl + url, {
body,
headers: {Authorization: 'Bearer ' + this.loginService.token}
}).toPromise();
}
async logout(): Promise<void> {
await this.request('post', '/users/logout', '');
this.loginService.token = null;
this.loginService.user = null;
}
}

View file

@ -6,8 +6,7 @@
<mat-toolbar>Menü</mat-toolbar>
<mat-nav-list>
<a mat-list-item routerLink="/">Főoldal</a>
<a mat-list-item href="#">Link 2</a>
<a mat-list-item href="#">Link 3</a>
<a mat-list-item *ngFor="let item of getMenuItems()" [routerLink]="'/' + item.path">{{ item.title }}</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
@ -22,10 +21,11 @@
</button>
<span>Szakdolgozat</span>
<span class="toolbar-spacer"></span>
<span *ngIf="loginService.loggedInUser">
<span *ngIf="loginService.token">
<span>{{ loginService.user.name }}</span>
<a mat-button (click)="logout()">Kijelentkezés</a>
</span>
<span *ngIf="!loginService.loggedInUser">
<span *ngIf="!loginService.token">
<a mat-button routerLink="/register">Regisztráció</a>
<a mat-button routerLink="/login">
Bejelentkezés

View file

@ -4,6 +4,8 @@ import {Observable} from 'rxjs';
import {map, shareReplay} from 'rxjs/operators';
import {LoginService} from './shared/login.service';
import {Router} from '@angular/router';
import {ApiService} from './api.service';
import {UserRole} from './model/user.model';
@Component({
selector: 'app-root',
@ -18,15 +20,25 @@ export class AppComponent implements OnInit {
shareReplay()
);
constructor(private breakpointObserver: BreakpointObserver, public loginService: LoginService,
private router: Router) {
menu: MenuItem[] = [
{path: 'subjects', title: 'Tárgyak', requiredRole: 'admin'}
];
constructor(private breakpointObserver: BreakpointObserver, public loginService: LoginService, private api: ApiService,
private router: Router, private login: LoginService) {
}
ngOnInit(): void {
}
async logout(): Promise<void> {
await this.loginService.logout();
await this.api.logout();
await this.router.navigate(['/']);
}
getMenuItems(): MenuItem[] {
return this.menu.filter(item => item.requiredRole === 'admin' ? this.login.user?.isAdmin : true); // TODO: Roles
}
}
type MenuItem = { path: string, title: string, requiredRole: UserRole | 'admin' };

View file

@ -17,10 +17,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {RegisterComponent} from './register/register.component';
import {LoginService} from './shared/login.service';
import {AngularFireModule, FirebaseApp} from '@angular/fire';
import {AngularFireAuthModule} from '@angular/fire/auth';
import {AngularFirestoreModule} from '@angular/fire/firestore';
import {AngularFireDatabaseModule} from '@angular/fire/database';
import {HttpClientModule} from '@angular/common/http';
@NgModule({
declarations: [
@ -43,10 +40,7 @@ import {AngularFireDatabaseModule} from '@angular/fire/database';
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
AngularFireModule.initializeApp((window as any).firebaseCredentials),
AngularFirestoreModule,
AngularFireDatabaseModule,
AngularFireAuthModule
HttpClientModule
],
providers: [
LoginService

View file

@ -3,13 +3,16 @@
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput class="mat-input-element" type="email" name="email" required="required"
placeholder="h123456@stud.u-szeged.hu" [(ngModel)]="email"/>
placeholder="h123456@stud.u-szeged.hu" [formControl]="email" [errorStateMatcher]="matcher"/>
<mat-hint>Egyetemi email cim</mat-hint>
</mat-form-field>
<mat-form-field>
<mat-label>Jelszó</mat-label>
<input matInput class="mat-input-element" type="password" name="password" required="required"
minlength="8" [(ngModel)]="pass"/>
minlength="8" [formControl]="pass" [errorStateMatcher]="matcher"/>
</mat-form-field>
<mat-error *ngIf="matcher.isErrorState(email, null)">
A megadott email cim vagy jelszó nem megfelelő
</mat-error>
<button mat-raised-button color="primary" (click)="doLogin()">Bejelentkezés</button>
</form>

View file

@ -1,6 +1,8 @@
import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {LoginService} from '../shared/login.service';
import {FormErrorStateMatcher} from '../utility/form-error-state-matcher';
import {FormControl} from '@angular/forms';
@Component({
selector: 'app-login',
@ -8,8 +10,9 @@ import {LoginService} from '../shared/login.service';
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
email: string;
pass: string;
email = new FormControl('');
pass = new FormControl('');
matcher = new FormErrorStateMatcher();
constructor(private router: Router, private loginService: LoginService) {
}
@ -19,7 +22,15 @@ export class LoginComponent implements OnInit {
}
async doLogin(): Promise<void> {
await this.loginService.login(this.email, this.pass);
await this.router.navigate(['/']);
if (await this.loginService.login(this.email.value, this.pass.value)) {
await this.router.navigate(['/']);
} else {
this.email.setErrors({
login: true
});
this.pass.setErrors({
login: true
});
}
}
}

View file

@ -0,0 +1,7 @@
export class User {
name: string;
isAdmin: boolean;
}
export type UserRole = 'teacher' | 'student';

View file

@ -16,6 +16,18 @@
Egyetemi email megadása szükséges
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Teljes név</mat-label>
<input matInput [formControl]="nameFormControl" [errorStateMatcher]="matcher"
class="mat-input-element" type="text" name="name" required="required"
minlength="4"/>
<mat-error *ngIf="nameFormControl.hasError('pattern') && !nameFormControl.hasError('required')">
A név formátuma nem megfelelő.
</mat-error>
<mat-error *ngIf="nameFormControl.hasError('required')">
A teljes név megadása kötelező.
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Jelszó</mat-label>
<input matInput [formControl]="passFormControl" [errorStateMatcher]="matcher"

View file

@ -1,15 +1,8 @@
import {Component, OnInit} from '@angular/core';
import {ErrorStateMatcher} from '@angular/material/core';
import {AbstractControl, FormControl, FormGroupDirective, NgForm, ValidationErrors, Validators} from '@angular/forms';
import {AbstractControl, FormControl, ValidationErrors, Validators} from '@angular/forms';
import {LoginService} from '../shared/login.service';
import {Router} from '@angular/router';
export class FormErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const isSubmitted = form && form.submitted;
return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted));
}
}
import {FormErrorStateMatcher} from '../utility/form-error-state-matcher';
@Component({
selector: 'app-register',
@ -27,6 +20,10 @@ export class RegisterComponent implements OnInit {
RegisterComponent.validateUniEmail
]);
nameFormControl = new FormControl('', [
Validators.pattern(/([A-Za-z-.]+ )+[A-Za-z-.]+/)
]);
passFormControl = new FormControl('', [
Validators.required,
Validators.minLength(8)
@ -75,7 +72,7 @@ export class RegisterComponent implements OnInit {
return;
}
try {
await this.loginService.createUser(this.emailFormControl.value, this.passFormControl.value);
await this.loginService.createUser(this.emailFormControl.value, this.passFormControl.value, this.nameFormControl.value);
await this.router.navigate(['/']);
} catch (e) {
alert(e);

View file

@ -1,21 +1,34 @@
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../environments/environment';
import {User} from '../model/user.model';
@Injectable({
providedIn: 'root'
})
export class LoginService {
loggedInUser: string; // TODO
token: string;
user: User;
constructor() {
constructor(private http: HttpClient) {
}
async createUser(email: string, pass: string): Promise<void> {
async createUser(email: string, password: string, name: string): Promise<void> {
await this.http.post(environment.backendUrl + '/users', {email, password, name}).toPromise();
}
async logout(): Promise<void> {
}
async login(email: string, pass: string): Promise<void> {
async login(email: string, password: string): Promise<boolean> {
try {
const resp: any = await this.http.post(environment.backendUrl + '/users/login', {email, password}).toPromise();
this.token = resp.token;
this.user = resp.user;
return true;
} catch (e) {
if (e.status === 401 || e.status === 422) {
return false;
}
throw e;
}
}
}

View file

@ -0,0 +1,9 @@
import {ErrorStateMatcher} from '@angular/material/core';
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
export class FormErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const isSubmitted = form && form.submitted;
return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted));
}
}

View file

@ -3,7 +3,8 @@
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
production: false,
backendUrl: 'http://localhost:8019'
};
/*