In the course is used firebase, but I prefer something local like json-server, since it is needed the authentication I'll use json-server-auth,
npm install -g json-server json-server-auth express # required by json-server-auth
json-server-auth --watch db.json -r routes.json
# server listening at http://localhost:3000
# user creation
curl -X POST http://localhost:3000/register \
-H 'Content-Type: application/json'\
-d '{"email":"user@example.com","password":"stron-password"}'
the content of db.json
must be:
{
"users": [],
"recipes": []
}
while routes.json
should be:
{
"users": 600,
"recipes": 660
}
it works like Linux, [owner/logged/public], 4
read, 2
write.
To manage the authentication, it can be created a new component that manages login and sign up:
<!-- auth.component.html -->
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<div class="alert alert-danger" *ngIf="!!error">
<p>{{error}}</p>
</div>
<div *ngIf="isLoading" style="text-align: center">
<!-- spinner created from https://loading.io/css -->
<app-loading-spinner></app-loading-spinner>
</div>
<form #authForm="ngForm" (ngSubmit)="onSubmit(authForm)" *ngIf="!isLoading">
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
class="form-control"
ngModel
name="email"
required
email />
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
class="form-control"
ngModel
name="password"
required
minlength="6" />
</div>
<div>
<button class="btn btn-primary" type="submit" [disabled]="!authForm.valid">
{{ isLoginMode ? 'Login' : 'Sign Up' }}
</button>
|
<button class="btn btn-primary" type="button" (click)="onSwitchMode()">
Switch to {{ isLoginMode ? 'Sign Up' : 'Login' }}
</button>
</div>
</form>
</div>
</div>
@Component({
// ...
})
export class AuthComponent implements OnInit {
isLoginMode = true;
isLoading = false;
error: string | undefined;
constructor(private authService: AuthService) { }
ngOnInit(): void { }
onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}
onSubmit(form: NgForm) {
this.isLoading = true;
const { email, password } = form.value;
let authObservable: Observable<AuthResponseData>;
if (this.isLoginMode) {
authObservable = this.authService.login(email, password);
} else {
authObservable = this.authService.signUp(email, password);
}
authObservable.subscribe(
response => {
this.isLoading = false;
},
errorMessage => {
this.error = errorMessage;
this.isLoading = false;
},
);
form.reset();
}
}
the authentication service is:
export interface AuthResponseData {
accessToken: string;
user: {
email: string;
id: number;
};
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private baseUrl = 'http://localhost:3000';
constructor(private http: HttpClient) { }
signUp(email: string, password: string) {
return this.http
.post<AuthResponseData>(`${this.baseUrl}/register`, {
email,
password,
})
.pipe(catchError(this.handleError));
}
login(email: string, password: string) {
return this.http
.post<AuthResponseData>(`${this.baseUrl}/login`, {
email,
password,
})
.pipe(catchError(this.handleError));
}
private handleError(errorResponse: HttpErrorResponse) {
let message = 'An error occurred!';
if (errorResponse.error) {
message = errorResponse.error;
}
return throwError(message);
}
}
the new route must be registered:
const routes: Routes = [
// ...
{ path: 'auth', component: AuthComponent}
];
@NgModule({
// ...
})
export class AppRoutingModule { }
and added to the header:
<!-- header.component.html -->
<!-- ... -->
<li routerLinkActive="active"><a routerLink="/auth">Authenticate</a></li>
<!-- ... -->
It is possible to introduce an user model that has all the information of the current logged user:
export class User {
constructor(
public id: number,
public email: string,
private _token: string,
private _tokenExpirationDate: Date,
) {}
get token(): string | undefined {
if (!this._token || !this._tokenExpirationDate || new Date() > this._tokenExpirationDate) {
return undefined;
}
return this._token;
}
}
and publish this data during login or sing up:
@Injectable({ providedIn: 'root' })
export class AuthService {
// ...
private oneHour = 60 * 60 * 1000;
public user = new Subject<User>();
// ...
signUp(email: string, password: string) {
return this.http
.post<AuthResponseData>(`${this.baseUrl}/register`, { email, password, })
.pipe(catchError(this.handleError), tap(this.handleAuthentication));
}
login(email: string, password: string) {
return this.http
.post<AuthResponseData>(`${this.baseUrl}/login`, { email, password, })
.pipe(catchError(this.handleError), tap(this.handleAuthentication));
}
private handleAuthentication(authData: AuthResponseData) {
const expirationDate = new Date(new Date().getTime() + this.oneHour);
var user = new User(
authData.user.id,
authData.user.email,
authData.accessToken,
expirationDate,
);
this.user.next(user);
}
// ...
}
The authentication can be managed in the header in this way:
<!-- header.component.html -->
<!-- ... -->
<ul class="nav navbar-nav">
<li routerLinkActive="active" *ngIf="isAuthenticated"><a routerLink="/recipes">Recipes</a></li>
<li routerLinkActive="active" *ngIf="!isAuthenticated"><a routerLink="/auth">Authenticate</a></li>
<li routerLinkActive="active"><a routerLink="/shopping-list">Shopping List</a></li>
</ul>
<ul class="nav navbar-nav navbar-right" *ngIf="isAuthenticated">
<li><a style="cursor: pointer;" >Logout</a></li>
<li class="dropdown" appDropdown>
<!-- ... -->
</li>
<!-- ... -->
@Component({
// ...
})
export class HeaderComponent implements OnInit, OnDestroy {
// ...
authSubscription: Subscription;
isAuthenticated = false;
constructor(private dataStorageService: DataStorageService, private authService: AuthService) {
this.authSubscription = authService.user.subscribe(user => {
this.isAuthenticated = !!user && !!user.token;
});
}
// ...
ngOnDestroy(): void {
this.authSubscription.unsubscribe();
}
}
It is necessary now to add the access token to the recipes' requests.
First of all using BehaviorSubject
, it is possible to get the first value of
a Subject even if it has not published any value:
@Injectable({ providedIn: 'root' })
export class AuthService {
public user = new BehaviorSubject<User | undefined>(undefined);
// ...
}
after that it is possible to authenticate the fetch call:
@Injectable({ providedIn: 'root' })
export class DataStorageService {
// ...
constructor(
// ...
private authService: AuthService,
) {}
// ...
fetchRecipes() {
return this.authService.user.pipe(
// automatically unsubscribe after 1 use
take(1),
// changes the observable in a new type
exhaustMap(user => {
return this.http.get<Recipe[]>(`${this.baseUrl}`, {
headers: new HttpHeaders().set('Authorization', `Bearer ${user?.token}`),
});
}),
// keep previously behaviour in the pipe
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : [],
};
});
}),
tap(recipes => {
this.recipeService.setRecipes(recipes);
}),
);
}
}
The same behaviour can be achieved using a request interceptor:
@Injectable({ providedIn: 'root' })
export class AuthInterceptorService implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return this.authService.user.pipe(
take(1),
exhaustMap(user => {
// exclude login / sign up
if (user) {
const authorizedRequest = req.clone({
headers: req.headers.set('Authorization', `Bearer ${user.token}`),
});
return next.handle(authorizedRequest);
}
return next.handle(req);
}),
);
}
}
the interceptor must be registered:
@NgModule({
declarations: [
// ...
],
// ...
providers: [
// ...
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true,
},
],
// ...
})
export class AppModule {}
and the fetch call in the DataStorageService
can be reset
as the previous implementation.
In order to logout it is enough to publish an undefined user:
@Injectable({ providedIn: 'root' })
export class AuthService {
// ...
constructor(private http: HttpClient, private router: Router) {}
// ...
logout() {
this.user.next(undefined);
}
// ...
}
the button can be added in the header:
<!-- header.component.html -->
<!-- ... -->
<li><a style="cursor: pointer;" (click)="onLogout()">Logout</a></li>
<!-- ... -->
@Component({
// ...
})
export class HeaderComponent implements OnInit, OnDestroy {
// ...
onLogout(){
this.authService.logout()
this.router.navigate(['/auth']);
}
// ...
}
It is possible to automatically login using the local store:
@Injectable({ providedIn: 'root' })
export class AuthService {
// ...
public tokenExpirationTimer: any;
// ...
// retrieve user from store
autoLogin() {
const userData = localStorage.getItem('userData');
if (userData) {
const { id, email, _token, _tokenExpirationDate } = JSON.parse(userData);
const curUser = new User(id, email, _token, new Date(_tokenExpirationDate));
if (!!curUser.token) {
this.user.next(curUser);
const expiration = new Date(_tokenExpirationDate).getTime() - new Date().getTime();
this.autoLogout(expiration);
}
}
}
logout() {
this.user.next(undefined);
this.router.navigate(['/auth']);
// clear storage
localStorage.removeItem('userData');
// delete timer if present
if (this.tokenExpirationTimer) {
clearTimeout(this.tokenExpirationTimer);
}
}
autoLogout(expirationDuration: number) {
this.tokenExpirationTimer = setTimeout(() => {
this.logout();
// }, 2000);
}, expirationDuration);
}
private handleAuthentication(authData: AuthResponseData) {
const expirationDate = new Date(new Date().getTime() + 60 * 60 * 1000);
var curUser = new User( authData.user.id, authData.user.email, authData.accessToken, expirationDate,);
this.user.next(curUser);
this.autoLogout(60 * 60 * 1000);
// saving user
localStorage.setItem('userData', JSON.stringify(curUser));
}
// ...
}
The auto login functionality can be added in the init method of the app component:
@Component({
// ...
})
export class AppComponent implements OnInit {
// ...
constructor(private authService: AuthService){}
ngOnInit(): void {
this.authService.autoLogin()
}
}
An auth guard with an automatically redirect to the login page can be easily implemented in this way:
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): boolean | UrlTree | Observable<boolean | UrlTree> | Promise<boolean | UrlTree> {
return this.authService.user.pipe(
// unsuscribe to do not have strange behaviours on future emitted values
take(1),
map(user => {
// old way
// return !!user?.token;
return !!user?.token ? true : this.router.createUrlTree(['/auth']);
}),
// old way
// tap(isAuth => {
// if (!isAuth) {
// console.log('redirect');
// this.router.navigate(['/auth']);
// }
// }),
);
}
}
and added in the app router:
const routes: Routes = [
// ...
{
path: 'recipes',
component: RecipesComponent,
canActivate: [AuthGuard],
children: [
// ...
],
},
// ...
];
@NgModule({
// ...
})
export class AppRoutingModule { }