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 {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 {User} from '../models';
import {UserRepository} from '../repositories'; import {UserRepository} from '../repositories';
import { import {
TokenServiceBindings, TokenServiceBindings,
UserServiceBindings UserServiceBindings
} from '@loopback/authentication-jwt'; } from '@loopback/authentication-jwt';
import {inject} from '@loopback/core'; import {inject} from '@loopback/core';
import {TokenService} from '@loopback/authentication'; import {authenticate, TokenService} from '@loopback/authentication';
import {SecurityBindings, UserProfile} from '@loopback/security'; import {SecurityBindings, UserProfile} from '@loopback/security';
import {genSalt, hash} from 'bcryptjs'; import {genSalt, hash} from 'bcryptjs';
import {SzakdolgozatUserService} from '../services'; import {SzakdolgozatUserService} from '../services';
export class UserController { export class UserController {
constructor( constructor(
@inject(TokenServiceBindings.TOKEN_SERVICE) @inject(TokenServiceBindings.TOKEN_SERVICE)
public jwtService: TokenService, public jwtService: TokenService,
@inject(UserServiceBindings.USER_SERVICE) @inject(UserServiceBindings.USER_SERVICE)
public userService: SzakdolgozatUserService, public userService: SzakdolgozatUserService,
@inject(SecurityBindings.USER, {optional: true}) @inject(SecurityBindings.USER, {optional: true})
public user: UserProfile, public user: UserProfile,
@repository(UserRepository) @repository(UserRepository)
public userRepository : UserRepository, public userRepository: UserRepository,
) { } ) {
}
@post('/users') @post('/users')
@response(200, { @response(200, {
description: 'User model instance', description: 'User model instance',
content: {'application/json': {schema: getModelSchemaRef(User, {exclude: ['password']})}}, content: {'application/json': {schema: getModelSchemaRef(User, {exclude: ['password']})}},
})
async register(
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {
title: 'Registration request',
exclude: ['id', 'role']
}),
},
},
}) })
request: Pick<User, 'email' | 'name' | 'password'>, async register(
): Promise<User> { @requestBody({
const password = await hash(request.password, await genSalt()); content: {
const user = { 'application/json': {
email: request.email, schema: getModelSchemaRef(User, {
name: request.name, title: 'Registration request',
password: password, exclude: ['id', 'isAdmin']
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',
}, },
},
}, },
}, })
}, request: Pick<User, 'email' | 'name' | 'password'>,
}, ): Promise<User> {
}, const password = await hash(request.password, await genSalt());
}) const user = {
async login( email: request.email,
@requestBody({ name: request.name,
description: 'The input of login function', password: password,
required: true, isAdmin: false
content: { } as User;
'application/json': { return this.userRepository.create(user);
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);
// create a JSON Web Token based on the user profile @post('/users/login', {
const token = await this.jwtService.generateToken(userProfile); responses: {
return {token}; '200': {
} description: 'Token',
content: {
@get('/users/count') 'application/json': {
@response(200, { schema: {
description: 'User model count', type: 'object',
content: {'application/json': {schema: CountSchema}}, properties: {
}) token: {
async count( type: 'string',
@param.where(User) where?: Where<User>, },
): Promise<Count> { user: getModelSchemaRef(User, {exclude: ['id', 'password']})
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}),
}, },
},
},
})
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, async login(
@param.where(User) where?: Where<User>, @requestBody({
): Promise<Count> { description: 'The input of login function',
return this.userRepository.updateAll(user, where); 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}') // create a JSON Web Token based on the user profile
@response(200, { const token = await this.jwtService.generateToken(userProfile);
description: 'User model instance', return {token, user};
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}') @post('/users/logout', {
@response(204, { responses: {
description: 'User PATCH success', '204': {
}) description: 'Logged out',
async updateById( },
@param.path.number('id') id: number,
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(User, {partial: true}),
}, },
},
}) })
user: User, @authenticate('jwt')
): Promise<void> { async logout(@inject(RestBindings.Http.REQUEST) request: Request): Promise<void> {
await this.userRepository.updateById(id, user); 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}') @get('/users/count')
@response(204, { @response(200, {
description: 'User PUT success', description: 'User model count',
}) content: {'application/json': {schema: CountSchema}},
async replaceById( })
@param.path.number('id') id: number, async count(
@requestBody() user: User, @param.where(User) where?: Where<User>,
): Promise<void> { ): Promise<Count> {
await this.userRepository.replaceById(id, user); return this.userRepository.count(where);
} }
@del('/users/{id}') @get('/users')
@response(204, { @response(200, {
description: 'User DELETE success', description: 'Array of User model instances',
}) content: {
async deleteById(@param.path.number('id') id: number): Promise<void> { 'application/json': {
await this.userRepository.deleteById(id); 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; password: string;
@property({ @property({
type: 'string', type: 'boolean',
required: true, required: true,
}) })
role: 'admin' | 'teacher' | 'student'; isAdmin: boolean;
constructor(data?: Partial<User>) { 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-toolbar>Menü</mat-toolbar>
<mat-nav-list> <mat-nav-list>
<a mat-list-item routerLink="/">Főoldal</a> <a mat-list-item routerLink="/">Főoldal</a>
<a mat-list-item href="#">Link 2</a> <a mat-list-item *ngFor="let item of getMenuItems()" [routerLink]="'/' + item.path">{{ item.title }}</a>
<a mat-list-item href="#">Link 3</a>
</mat-nav-list> </mat-nav-list>
</mat-sidenav> </mat-sidenav>
<mat-sidenav-content> <mat-sidenav-content>
@ -22,10 +21,11 @@
</button> </button>
<span>Szakdolgozat</span> <span>Szakdolgozat</span>
<span class="toolbar-spacer"></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> <a mat-button (click)="logout()">Kijelentkezés</a>
</span> </span>
<span *ngIf="!loginService.loggedInUser"> <span *ngIf="!loginService.token">
<a mat-button routerLink="/register">Regisztráció</a> <a mat-button routerLink="/register">Regisztráció</a>
<a mat-button routerLink="/login"> <a mat-button routerLink="/login">
Bejelentkezés Bejelentkezés

View file

@ -4,6 +4,8 @@ import {Observable} from 'rxjs';
import {map, shareReplay} from 'rxjs/operators'; import {map, shareReplay} from 'rxjs/operators';
import {LoginService} from './shared/login.service'; import {LoginService} from './shared/login.service';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {ApiService} from './api.service';
import {UserRole} from './model/user.model';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -18,15 +20,25 @@ export class AppComponent implements OnInit {
shareReplay() shareReplay()
); );
constructor(private breakpointObserver: BreakpointObserver, public loginService: LoginService, menu: MenuItem[] = [
private router: Router) { {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 { ngOnInit(): void {
} }
async logout(): Promise<void> { async logout(): Promise<void> {
await this.loginService.logout(); await this.api.logout();
await this.router.navigate(['/']); 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 {MatInputModule} from '@angular/material/input';
import {RegisterComponent} from './register/register.component'; import {RegisterComponent} from './register/register.component';
import {LoginService} from './shared/login.service'; import {LoginService} from './shared/login.service';
import {AngularFireModule, FirebaseApp} from '@angular/fire'; import {HttpClientModule} from '@angular/common/http';
import {AngularFireAuthModule} from '@angular/fire/auth';
import {AngularFirestoreModule} from '@angular/fire/firestore';
import {AngularFireDatabaseModule} from '@angular/fire/database';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -43,10 +40,7 @@ import {AngularFireDatabaseModule} from '@angular/fire/database';
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
ReactiveFormsModule, ReactiveFormsModule,
AngularFireModule.initializeApp((window as any).firebaseCredentials), HttpClientModule
AngularFirestoreModule,
AngularFireDatabaseModule,
AngularFireAuthModule
], ],
providers: [ providers: [
LoginService LoginService

View file

@ -3,13 +3,16 @@
<mat-form-field> <mat-form-field>
<mat-label>Email</mat-label> <mat-label>Email</mat-label>
<input matInput class="mat-input-element" type="email" name="email" required="required" <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-hint>Egyetemi email cim</mat-hint>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label>Jelszó</mat-label> <mat-label>Jelszó</mat-label>
<input matInput class="mat-input-element" type="password" name="password" required="required" <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-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> <button mat-raised-button color="primary" (click)="doLogin()">Bejelentkezés</button>
</form> </form>

View file

@ -1,6 +1,8 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {LoginService} from '../shared/login.service'; import {LoginService} from '../shared/login.service';
import {FormErrorStateMatcher} from '../utility/form-error-state-matcher';
import {FormControl} from '@angular/forms';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@ -8,8 +10,9 @@ import {LoginService} from '../shared/login.service';
styleUrls: ['./login.component.css'] styleUrls: ['./login.component.css']
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
email: string; email = new FormControl('');
pass: string; pass = new FormControl('');
matcher = new FormErrorStateMatcher();
constructor(private router: Router, private loginService: LoginService) { constructor(private router: Router, private loginService: LoginService) {
} }
@ -19,7 +22,15 @@ export class LoginComponent implements OnInit {
} }
async doLogin(): Promise<void> { async doLogin(): Promise<void> {
await this.loginService.login(this.email, this.pass); if (await this.loginService.login(this.email.value, this.pass.value)) {
await this.router.navigate(['/']); 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 Egyetemi email megadása szükséges
</mat-error> </mat-error>
</mat-form-field> </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-form-field>
<mat-label>Jelszó</mat-label> <mat-label>Jelszó</mat-label>
<input matInput [formControl]="passFormControl" [errorStateMatcher]="matcher" <input matInput [formControl]="passFormControl" [errorStateMatcher]="matcher"

View file

@ -1,15 +1,8 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {ErrorStateMatcher} from '@angular/material/core'; import {AbstractControl, FormControl, ValidationErrors, Validators} from '@angular/forms';
import {AbstractControl, FormControl, FormGroupDirective, NgForm, ValidationErrors, Validators} from '@angular/forms';
import {LoginService} from '../shared/login.service'; import {LoginService} from '../shared/login.service';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {FormErrorStateMatcher} from '../utility/form-error-state-matcher';
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));
}
}
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
@ -27,6 +20,10 @@ export class RegisterComponent implements OnInit {
RegisterComponent.validateUniEmail RegisterComponent.validateUniEmail
]); ]);
nameFormControl = new FormControl('', [
Validators.pattern(/([A-Za-z-.]+ )+[A-Za-z-.]+/)
]);
passFormControl = new FormControl('', [ passFormControl = new FormControl('', [
Validators.required, Validators.required,
Validators.minLength(8) Validators.minLength(8)
@ -75,7 +72,7 @@ export class RegisterComponent implements OnInit {
return; return;
} }
try { 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(['/']); await this.router.navigate(['/']);
} catch (e) { } catch (e) {
alert(e); alert(e);

View file

@ -1,21 +1,34 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../environments/environment';
import {User} from '../model/user.model';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class LoginService { 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, password: string): Promise<boolean> {
} try {
const resp: any = await this.http.post(environment.backendUrl + '/users/login', {email, password}).toPromise();
async login(email: string, pass: string): Promise<void> { 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`. // The list of file replacements can be found in `angular.json`.
export const environment = { export const environment = {
production: false production: false,
backendUrl: 'http://localhost:8019'
}; };
/* /*