Designing a high-quality authentication and authorization system without frustrating the end user is a difficult problem to solve. Authentication is the act of verifying the identity of a user, and authorization specifies the privileges that a user must have to access a resource. Both processes, auth for short, must seamlessly work in tandem to address the needs of users with varying roles, needs, and job functions.
On today's web, users have a high baseline level of expectations from any auth system they encounter through the browser, so this is an important part of your application to get absolutely right the first time. The user should always be aware of what they can and can't do in your application. If there are errors, failures, or mistakes, the user should be clearly informed about why they occurred. As your application grows, it will be easy to miss all the ways that an error condition could be triggered. Your implementation should be easy to extend or maintain, otherwise this basic backbone of your application will require a lot of maintenance. In this chapter, we will walk through the various challenges of creating a great auth UX and implement a solid baseline experience.
We will continue the router-first approach to designing SPAs by implementing the auth experience of LemonMart. In Chapter 7, Creating a Router-First Line-of-Business App, we defined user roles, finished our build-out of all major routing, and completed a rough walking-skeleton navigation experience of LemonMart. This means that we are well prepared to implement a role-based conditional navigation experience that captures the nuances of a seamless auth experience.
In this chapter, we will implement a token-based auth scheme around the User entity that we defined in the last chapter. For a robust and maintainable implementation, we will deep dive into object-oriented programming (OOP) with abstraction, inheritance, and factories, along with implementing a cache service, a UI service, and two different auth schemes: an in-memory fake auth service for educational purposes and a Google Firebase auth service that you can leverage in real-world applications.
In this chapter, you will learn about the following topics:
The most up-to-date versions of the sample code for the book are on GitHub at the linked repository that follows. The repository contains the final and completed state of the code. You can verify your progress at the end of this chapter by looking for the end-of-chapter snapshot of code under the projects
folder.
For Chapter 8:
npm install
on the root folder to install dependenciesprojects/ch8
npx ng serve ch8
npx ng test ch8 --watch=false
npx ng e2e ch8
npx ng build ch8 --prod
Note that the dist/ch8
folder at the root of the repository will contain the compiled result.
Be aware that the source code in the book or on GitHub may not always match the code generated by Angular CLI. There may also be slight differences in implementation between the code in the book and what's on GitHub because the ecosystem is ever-evolving. It is natural for the sample code to change over time. Also, on GitHub, expect to find corrections, fixes to support newer versions of libraries, or side-by-side implementations of multiple techniques for you to observe. You are only expected to implement the ideal solution recommended in the book. If you find errors or have questions, please create an issue or submit a pull request on GitHub for the benefit of all readers.
Let's start with going over how a token-based auth workflow functions.
A well-designed authentication workflow is stateless so that there's no concept of an expiring session. Users are free to interact with your stateless REST APIs from as many devices and tabs as they wish, simultaneously or over time. JSON Web Token (JWT) implements distributed claims-based authentication that can be digitally signed or integration that is protected and/or encrypted using a Message Authentication Code (MAC). This means that once a user's identity is authenticated (that is, a password challenge on a login form), they receive an encoded claim ticket or a token, which can then be used to make future requests to the system without having to reauthenticate the identity of the user.
The server can independently verify the validity of this claim and process the requests without requiring any prior knowledge of having interacted with this user. Thus, we don't have to store session information regarding a user, making our solution stateless and easy to scale. Each token will expire after a predefined period and due to their distributed nature, they can't be remotely or individually revoked; however, we can bolster real-time security by interjecting custom account and user role status checks to ensure that the authenticated user is authorized to access server-side resources.
JWTs implement the Internet Engineering Task Force (IETF) industry standard RFC 7519, found at https://tools.ietf.org/html/rfc7519.
A good authorization workflow enables conditional navigation based on a user's role so that users are automatically taken to the optimal landing screen; they are not shown routes or elements that are not suitable for their roles and if, by mistake, they try to access a restricted path, they are prevented from doing so. You must remember that any client-side role-based navigation is merely a convenience and is not meant for security. This means that every call made to the server should contain the necessary header information, with the secure token, so that the user can be reauthenticated by the server and their role independently verified. Only then will they be allowed to retrieve secured data. Client-side authentication can't be trusted, which is why password reset screens must be built with a server-side rendering technology so that both the user and the server can verify that the intended user is interacting with the system.
JWTs complement a stateless REST API architecture with an encrypted token mechanism that allows convenient, distributed, and high-performance authentication and authorization of requests sent by clients. There are three main components of a token-based authentication scheme:
A secure system presumes that data sent/received between clients (applications and browsers), systems (servers and services), and databases is encrypted using transport layer security (TLS), which is essentially a newer version of secure sockets layer (SSL). This means that your REST API must be hosted with a properly configured SSL certificate, serving all API calls over HTTPS, so that user credentials are never exposed between the client and the server. Similarly, any database or third-party service call should happen over TLS. This ensures the security of the data in transit.
At-rest (when the data is sitting in the database) passwords should be stored using a secure one-way hashing algorithm with good salting practices.
Did all the talk of hashing and salting make you think of breakfast? Unfortunately, they're cryptography-related terms. If you're interested in learning more, check out this article: https://crackstation.net/hashing-security.htm.
Sensitive user information, such as personally identifiable information (PII), should be encrypted at rest with a secure two-way encryption algorithm, unlike passwords. Passwords are hashed, so we verify that the user is providing the same password without the system knowing what the password is. With PII, we must be able to decrypt the data so that we can display it to the user. But since the data is encrypted at rest, if the database is compromised then the hacked data is worthless.
Following a layered approach to security is critical, because attackers will need to accomplish the unlikely feat of compromising all layers of your security at the same time to cause meaningful harm to your business.
Fun fact: When you hear about massive data breaches from major corporations, most of the time the root cause is a lack of proper implementation of in-transit or at-rest security. Sometimes this is because it is too computationally expensive to continually encrypt/decrypt data, so engineers rely on being behind firewalls. In that case, once the outer perimeter is breached, as they say, the fox has access to the hen house.
Consider the following sequence diagram, which highlights the life cycle of JWT-based authentication:
Figure 8.1: The life cycle of JWT-based authentication
Initially, a user logs in by providing their username and password. Once validated, the user's authentication status and role are encrypted in a JWT with an expiration date and time, and it is sent back to the browser.
Our Angular (or any other) application can cache this token in local or session storage securely so that the user isn't forced to log in with every request. This way, we don't resort to insecure practices like storing user credentials in cookies to provide a good UX.
You will get a better understanding of the JWT life cycle when you implement your own auth service later in this chapter. In the following sections, we will design a fully featured auth workflow around the User data entity, as follows:
Figure 8.2: The User entity
The User entity described is slightly different to our initial entity model. The entity model reflects how data is stored in the database. The entity is a flattened (or simplified) representation of the user record. Even a flattened entity has complex objects, like name, which has properties for first, middle, and last. Furthermore, not all properties are required. Additionally, when interacting with auth systems and other APIs, we may receive incomplete, incorrect, or maliciously formed data, so our code will have to effectively deal with null
and undefined
variables.
Next, let's see how we can leverage TypeScript operators to effectively deal with unexpected data.
JavaScript is a dynamically typed language. At runtime, the JavaScript engine executing our code, like Chrome's V8, doesn't know the type of the variable we're using. As a result, the engine must infer the type. We can have basic types like boolean
, number
, array
, or string
, or we can have a complex type, which is essentially a JSON object. In addition, variables can be null
or undefined
. In broad terms, undefined
represents something that hasn't been initialized and null
represents something that isn't currently available.
In strongly typed languages, the concept of undefined
doesn't exist. Basic types have default values, like a number
is a zero or a string
is an empty string. However, complex types can be null
. A null
reference means that the variable is defined, but there's no value behind it.
The inventor of the null
reference, Tony Hoare, called it his "billion-dollar mistake."
TypeScript brings the concepts of strongly typed languages to JavaScript, so it must bridge the gap between the two worlds. As a result, TypeScript defines types like null
, undefined
, any
, and never
to make sense of JavaScript's type semantics. I've added links to relevant TypeScript documentation in the Further reading section for a deeper dive into TypeScript types.
As the TypeScript documentation puts it, TypeScript treats null
and undefined
differently in order to match the JavaScript semantics. For example, the union type string | null
is a different type than string | undefined
and string | undefined | null
.
There's another nuance: checking to see whether a value equals null
using ==
versus ===
. Using the double equals operator, checking that foo != null
means that foo
is defined and not null
. However, using the triple equals operator, foo !== null
means that foo
is not null
, but could be undefined
. However, these two operators don't consider the truthiness of the variable, which includes the case of an empty string.
These subtle differences have a great impact on how you write code, especially when using the strict TypeScript rules that are applied when you create your Angular application using the --strict
option. It is important to remember that TypeScript is a development time tool and not a runtime tool. At runtime, we're still dealing with the realities of a dynamically typed language. Just because we declared a type to be a string, it doesn't mean that we will receive a string.
Next, let's see how we can deal with issues related to working with unexpected values.
When working with other libraries or dealing with information sent or received outside of your application, you must deal with the fact that the variable you receive might be null
or undefined
.
Outside of your application means dealing with user input, reading from a cookie or localStorage
, URL parameters from the router, or an API call over HTTP, to name a few examples.
In our code, we mostly care about the truthiness of a variable. This means that a variable is defined, not null, and if it's a basic type, it has a non-default value. Given a string
, we can check whether the string
is truthy with a simple if
statement:
example
const foo: string = undefined
if(foo) {
console.log('truthy')
} else {
console.log('falsy')
}
If foo
is null
, undefined
, or an empty string, the variable will be evaluated as falsy
. For certain situations, you may want to use the conditional or ternary operator instead of if-else
.
The conditional or ternary operator has the ?:
syntax. On the left-hand side of the question mark, the operator takes a conditional statement. On the right-hand side, we provide the outcomes for true and false around the colon: conditional ? true-outcome : false-outcome
. The conditional or ternary operator is a compact way to represent if-else
conditions, and can be very useful for increasing the readability of your code base. This operator is not a replacement for an if-else
block, but it is great when you're using the output of the if-else
condition.
Consider the following example:
example
const foo: string = undefined
let result = ''
if(foo) {
result = 'truthy'
} else {
result = 'falsy'
}
console.log(result)
The preceding if-else
block can be re-written as:
example
const foo: string = undefined
console.log(foo ? 'truthy' : 'falsy')
In this case, the conditional or ternary operator makes the code more compact and easier to understand at a glance. Another common scenario is returning a default value, where the variable is falsy
.
We will consider the null coalescing operator next.
The null coalescing operator is ||
. This operator saves us from repetition, when the truthy result of the conditional is the same as the conditional itself.
Consider the example where if foo
is defined, we would like to use the value of foo
, but if it is undefined
, we need a default value of 'bar'
:
example
const foo: string = undefined
console.log(foo ? foo : 'bar')
As you can see, foo
is repeated twice. We can avoid the duplication by using the null coalescing operator:
example
const foo: string = undefined
console.log(foo || 'bar')
So, if foo
is undefined
, null
or an empty string, bar
will be output. Otherwise, the value of foo
will be used. But in some cases, we need to only use the default value if the value is undefined
or null
. We will consider the nullish coalescing operator next.
The nullish coalescing operator is ??
. This operator is like the null coalescing operator, with one crucial difference. Checking the truthiness of a variable is not enough when dealing with data received from an API or user input, where an empty string may be a valid value. As we covered earlier in this section, checking for null
and undefined
is not as straightforward as it seems. But we know that by using the double equals operator, we can ensure that foo
is defined and not null:
example
const foo: string = undefined
console.log(foo != null ? foo : 'bar')
In the preceding case, if foo
is an empty string or another value, we will get the value of foo
output. If it is null
or undefined
, we will get 'bar'
. A more compact way to do this is by using the nullish coalescing operator:
example
const foo: string = undefined
console.log(foo ?? 'bar')
The preceding code will yield the same result as the previous example. However, when dealing with complex objects, we need to consider whether their properties are null
or undefined
as well. For this, we will consider the optional chaining operator.
The optional chaining operator is ?
. It is like Angular's safe navigation operator, which was covered in Chapter 3, Creating a Basic Angular App. Optional chaining ensures that a variable or property is defined and not null
before attempting to access a child property or invoke a function. So the statement foo?.bar?.callMe()
executes without throwing an error, even if foo
or bar
is null
or undefined
.
Consider the User entity, which has a name
object with properties for first
, middle
, and last
. Let's see what it would take to safely provide a default value of an empty string for a middle name using the nullish coalescing operator:
example
const user = {
name: {
first: 'Doguhan',
middle: null,
last: 'Uluca'
}
}
console.log((user && user.name && user.name.middle) ?? '')
As you can see, we need to check whether a parent object is truthy before accessing a child property. If middle
is null
, an empty string is output. Optional chaining makes this task simpler:
example
console.log(user?.name?.middle ?? '')
Using optional chaining and the nullish coalescing operator together, we can eliminate repetition and deliver robust code that can effectively deal with the realities of JavaScript's dynamic runtime.
So, when designing your code, you have to make decisions on whether to introduce the concept of null to your logic or work with default values like empty strings. In the next section, as we implement the User entity, you will see how these choices play out. So far, we have only used interfaces to define the shape of our data. Next, let's build the User entity, leveraging OOP concepts like classes, enums, and abstraction to implement it, along with an auth service.
As mentioned, we have only worked with interfaces to represent data. We still want to continue using interfaces when passing data around various components and services. Interfaces are great for describing the kind of properties or functions an implementation has, but they suggest nothing about the behavior of these properties or functions.
With ES2015 (ES6), JavaScript gained native support for classes, which is a crucial concept of the OOP paradigm. Classes are actual implementations of behavior. As opposed to just having a collection of functions in a file, a class can properly encapsulate behavior. A class can then be instantiated as an object using the new keyword.
TypeScript takes the ES2015 (and beyond) implementation of classes and introduces necessary concepts like abstract classes, private, protected, and public properties, and interfaces to make it possible to implement OOP patterns.
OOP is an imperative programming style, compared to the reactive programming style that RxJS enables. Classes form the bedrock of OOP, whereas observables do the same for reactive programming using RxJS.
I encourage you to become familiar with OOP terminology. Please see the Further reading section for some useful resources. You should become familiar with:
As you know, Angular uses OOP patterns to implement components and services. For example, interfaces are used to implement life cycle hooks such as OnInit
. Let's see how these patterns are implemented within the context of JavaScript classes.
In this section, I will demonstrate how you can use classes in your own code design to define and encapsulate the behavior of your models, such as the User
class. Later in this chapter, you will see examples of class inheritance with abstract base classes, which allows us to standardize our implementation and reuse base functionality in a clean and easy-to-maintain manner.
I must point out that OOP has very useful patterns that can increase the quality of your code; however, if you overuse it then you will start losing the benefits of the dynamic, flexible, and functional nature of JavaScript.
Sometimes all you need are a bunch of functions in a file, and you'll see examples of that throughout the book.
A great way to demonstrate the value of classes would be to standardize the creation of a default User
object. We need this because a BehaviorSubject
object needs to be initialized with a default object. It is best to do this in one place, rather than copy-paste the same implementation in multiple places. It makes a lot of sense for the User
object to own this functionality instead of an Angular service creating default User
objects. So, let's implement a User
class to achieve this goal.
Let's begin by defining our interfaces and enums:
enum
at the location src/app/auth/auth.enum.ts
:
src/app/auth/auth.enum.ts
export enum Role {
None = 'none',
Clerk = 'clerk',
Cashier = 'cashier',
Manager = 'manager',
}
user.ts
file under the src/app/user/user
folder.IUser
in the user.ts
file:
src/app/user/user/user.ts
import { Role } from '../../auth/auth.enum'
export interface IUser {
_id: string
email: string
name: IName
picture: string
role: Role | string
userStatus: boolean
dateOfBirth: Date | null | string
level: number
address: {
line1: string
line2?: string
city: string
state: string
zip: string
}
phones: IPhone[]
}
Note that every complex property that is defined on the interface can also be represented as a string
. In transit, all objects are converted to strings using JSON.stringify()
. No type information is included. We also leverage interfaces to represent Class
objects in-memory, which can have complex types. So, our interface properties must reflect both cases using union types. For example, role
can either be of type Role
or string
. Similarly, dateOfBirth
can be a Date
or a string
.
We define address
as an inline type, because we don't use the concept of an address outside of this class. In contrast, we define IName
as its own interface, because in Chapter 11, Recipes – Reusability, Routing, and Caching, we will implement a separate component for names. We also define a separate interface for phones, because they are represented as an array. When developing a form, we need to be able to address individual array elements, like IPhone
, in the template code.
It is the norm to insert a capital I
in front of interface names so they are easy to identify. Don't worry, there are no compatibility issues with using the IPhone
interface on Android phones!
user.ts
, define the IName
and IPhone
interfaces, and implement the PhoneType
enum:
src/app/user/user/user.ts
export interface IName {
first: string
middle?: string
last: string
}
export enum PhoneType {
None = 'none',
Mobile = 'mobile',
Home = 'home',
Work = 'work',
}
export interface IPhone {
type: PhoneType
digits: string
id: number
}
Note that in the PhoneType
enum, we explicitly defined string
values. By default, enum
values are converted into strings as they're typed, which can lead to issues with values stored in a database falling out of sync with how a developer chooses to spell a variable name. With explicit and all lowercase values, we reduce the risk of bugs.
User
class, which implements the IUser
interface:
src/app/user/user/user.ts
export class User implements IUser {
constructor(
// tslint:disable-next-line: variable-name
public _id = '',
public email = '',
public name = { first: '', middle: '', last: '' } as IName,
public picture = '',
public role = Role.None,
public dateOfBirth: Date | null = null,
public userStatus = false,
public level = 0,
public address = {
line1: '',
city: '',
state: '',
zip: '',
},
public phones: IPhone[] = []
) {}
static Build(user: IUser) {
if (!user) {
return new User()
}
if (typeof user.dateOfBirth === 'string') {
user.dateOfBirth = new Date(user.dateOfBirth)
}
return new User(
user._id,
user.email,
user.name,
user.picture,
user.role as Role,
user.dateOfBirth,
user.userStatus,
user.level,
user.address,
user.phones
)
}
}
Note that by defining all properties with default values in the constructors as public
properties, we hit two birds with one stone; otherwise, we would need to define properties and initialize them separately. This way, we achieve a concise implementation.
Using a static Build
function, we can quickly hydrate the object with data received from the server. We can also implement the toJSON()
function to customize the serialization behavior of our object before sending the data up to the server. But before that, let's add a calculated property.
We can use calculated properties in templates or in toast messages to conveniently display values assembled from multiple parts. A great example is extracting a full name from the name
object as a property in the User
class.
A calculated property for assembling a full name encapsulates the logic for combining a first, middle, and last name, so you don't have to rewrite this logic in multiple places, adhering to the DRY principle!
fullName
property getter in the User
class:
src/app/user/user/user.ts
export class User implements IUser {
...
public get fullName(): string {
if (!this.name) {
return ''
}
if (this.name.middle) {
return `${this.name.first} ${this.name.middle} ${this.name.last}`
}
return `${this.name.first} ${this.name.last}`
}
}
fullName
IUser
as readonly
and an optional property:
src/app/user/user/user.ts
export interface IUser {
...
readonly fullName?: string
}
You can now use the fullName
property through the IUser
interface.
toJSON
function:
src/app/user/user/user.ts
export class User implements IUser {
...
toJSON(): object {
const serialized = Object.assign(this)
delete serialized._id
delete serialized.fullName
return serialized
}
}
Note that when serializing the object, we delete the _id
and fullName
fields. These are values that we don't want to be stored in the database. The fullName
field is a calculated property, so it doesn't need to be stored. The _id
is normally passed as a parameter in a GET
or a PUT
call to locate the record. This avoids mistakes that may result in overwriting the id
fields of existing objects.
Now that we have the User data
entity implemented, next let's implement the auth service.
We aim to design a flexible auth service that can implement multiple auth providers. In this chapter, we will implement an in-memory provider and a Google Firebase provider. In Chapter 10, RESTful APIs and Full-Stack Implementation, we will implement a custom provider to interact with our backend.
By declaring an abstract base class, we can describe the common login and logout behavior of our application, so when we implement another auth provider, we don't have to re-engineer our application.
In addition, we can declare abstract functions, which the implementors of our base class would have to implement, enforcing our design. Any class that implements the base class would also get the benefit of the code implemented in the base class, so we wouldn't need to repeat the same logic in two different places.
The following class diagram reflects the architecture and inheritance hierarchy of our abstract AuthService
:
Figure 8.3: The AuthService inheritance structure
AuthService
implements the interface IAuthService
, as shown:
export interface IAuthService {
readonly authStatus$: BehaviorSubject<IAuthStatus>
readonly currentUser$: BehaviorSubject<IUser>
login(email: string, password: string): Observable<void>
logout(clearToken?: boolean): void
getToken(): string
}
The interface reflects the public properties that the service exposes. The service provides the authentication status as the authStatus$
observable and the current user as currentUser$
, and it provides three functions to login
, logout
, and getToken
.
AuthService
inherits caching functionality from another abstract class called CacheService
. Since AuthService
is an abstract class, it can't be used on its own, so we implement three auth providers, InMemoryAuthService
, FirebaseAuthService
, and CustomAuthService
, as seen at the bottom of the diagram.
Note that all three auth services implement all abstract functions. In addition, the FirebaseAuthService
overrides the base logout
function to implement its own behavior. All three classes inherit from the same abstract class and expose the same public interface. All three will execute the same auth workflow against different auth servers.
The in-memory auth service doesn't communicate with a server. The service is for demonstration purposes only. It implements fake JWT encoding, so we can demonstrate how the JWT life cycle works.
Let's start by creating the auth service.
We will start by creating the abstract auth service and the in-memory service:
$ npx ng g s auth --flat false --lintFix
$ npx ng g s auth/inMemoryAuth --lintFix --skipTests
in-memory-auth.service.ts
to auth.inmemory.service.ts
so the different auth providers visually group together in File Explorer.{ providedIn: 'root' }
from the @Injectable()
decorator of auth.service.ts
and auth.inmemory.service.ts
.authService
is provided in app.module.ts
, but the InMemoryAuthService
is actually used and not the abstract class:
src/app/app.module.ts
import { AuthService } from './auth/auth.service'
import { InMemoryAuthService } from './auth/auth.inmemory.service'
...
providers: [
{
provide: AuthService,
useClass: InMemoryAuthService
},
...
]
Creating a separate folder for the service organizes various components related to auth, such as the enum
definition for the user role. Additionally, we will be able to add an authService
fake to the same folder for automated testing.
Now, let's build an abstract auth service that will orchestrate logins and logouts, while encapsulating the logic of how to manage JWTs, auth status, and information regarding the current user. By leveraging the abstract class, we should be able to implement our own auth service against any auth provider without modifying the internal behavior of our application.
The abstract auth service that is being demonstrated enables rich and intricate workflows. It is a solution that you can drop into your applications without modifying the internal logic. As a result, it is a complicated solution.
This auth service will enable us to demonstrate logging in with an email and password, caching, and conditional navigation concepts based on authentication status and a user's role:
$ npm install jwt-decode
$ npm install -D @types/jwt-decode
IAuthStatus
interface to store decoded user information, a helper interface, and the secure by default defaultAuthStatus
:
src/app/auth/auth.service.ts
import { Role } from './auth.enum'
...
export interface IAuthStatus {
isAuthenticated: boolean
userRole: Role
userId: string
}
export interface IServerAuthResponse {
accessToken: string
}
export const defaultAuthStatus: IAuthStatus = {
isAuthenticated: false,
userRole: Role.None,
userId: '',
}
...
IAuthStatus
is an interface that represents the shape of a typical JWT that you may receive from your authentication service. It contains minimal information about the user and the user's role. The auth status object can be attached to the header of every REST call to APIs to verify the user's identity. The auth status can be optionally cached in localStorage
to remember the user's login state; otherwise, they would have to re-enter their password with every page refresh.
In the preceding implementation, we're assuming the default role of None
, as defined in the Role
enum. By not giving any role to the user by default, we're following a least-privileged access model. The user's correct role will be set after they log in successfully with the information received from the auth API.
IAuthService
interface in auth.service.ts
:
src/app/auth/auth.service.ts
export interface IAuthService {
readonly authStatus$: BehaviorSubject<IAuthStatus>
readonly currentUser$: BehaviorSubject<IUser>
login(email: string, password: string): Observable<void>
logout(clearToken?: boolean): void
getToken(): string
}
AuthService
an abstract
class, as shown:
export abstract class AuthService
IAuthService
, using VS Code's quick fix functionality:
src/app/auth/auth.service.ts
@Injectable()
export abstract class AuthService implements IAuthService {
authStatus$: BehaviorSubject<IAuthStatus>
currentUser$: BehaviorSubject<IUser>
constructor() {}
login(email: string, password: string): Observable<void> {
throw new Error('Method not implemented.')
}
logout(clearToken?: boolean): void {
throw new Error('Method not implemented.')
}
getToken(): string {
throw new Error('Method not implemented.')
}
}
authStatus$
and currentUser$
properties as readonly
and initialize our data anchors with their default values:
src/app/auth/auth.service.ts
import { IUser, User } from '../user/user/user'
...
@Injectable()
export abstract class AuthService implements IAuthService {
readonly authStatus$ =
new BehaviorSubject<IAuthStatus>(defaultAuthStatus)
readonly currentUser$ =
new BehaviorSubject<IUser>(new User())
...
}
Note that we removed the type definitions of the properties. Instead, we're letting TypeScript infer the type from the initialization.
You must always declare your data anchors as readonly
, so you don't accidentally overwrite the data stream by re-initializing a data anchor as a new BehaviorSubject
. Doing so would render any prior subscribers orphaned, leading to memory leaks, and have many unintended consequences.
All implementors of IAuthService
need to be able to log the user in, transform the token we get back from the server so we can read it and store it, support access to the current user, and the auth status, and provide a way to log the user out. We have successfully put in the functions for our public methods and implemented default values for our data anchors to create hooks for the rest of our application to use. But so far, we have only defined what our service can do, and not how it can do it.
As always, the devil is in the details, and the hard part is the "how." Abstract functions can help us to complete the implementation of a workflow in a service within our application, while leaving the portions of the service that must implement external APIs undefined.
Auth services that implement the abstract class should be able to support any kind of auth provider, and any kind of token transformation, while being able to modify behaviors like user retrieval logic. We must be able to implement login, logout, token, and auth status management without implementing calls to specific services.
By defining abstract functions, we can declare a series of methods that must implement a given set of inputs and outputs—a signature without an implementation. We can then use these abstract functions to orchestrate the implementation of our auth workflow.
Our design goal here is driven by the Open/Closed principle. The AuthService
will be open to extension through its ability to be extended to work with any kind of token-based auth provider, but closed to modification. Once we're done implementing the AuthService
, we won't need to modify its code to add additional auth providers.
Now we need to define the abstract functions that our auth providers must implement, as shown in Figure 8.3 from earlier in the chapter:
authProvider(email, password)
: Observable<IServerAuthResponse>
can log us in via a provider and return a standardized IServerAuthResponse
transformJwtToken(token)
: IAuthStatus
can normalize the token a provider returns to the interface of IAuthStatus
getCurrentUser()
: Observable<User>
can retrieve the user profile of the logged-in userWe can then use these functions in our login
, logout
, and getToken
methods to implement the auth workflow:
src/app/auth/auth.service.ts
...
export abstract class AuthService implements IAuthService {
protected abstract authProvider(
email: string,
password: string
): Observable<IServerAuthResponse>
protected abstract transformJwtToken(token: unknown):
IAuthStatus
protected abstract getCurrentUser(): Observable<User>
...
}
Leveraging these stubbed out methods, we can now implement a login
method that performs a login and retrieves the currently logged-in user, making sure to update the authStatus$
and currentUser$
data streams.
transformError
function to handle errors of different types like HttpErrorResponse
and string
, providing them in an observable stream. In a new file named common.ts
under src/app/common
create the transformError
function:
src/app/common/common.ts
import { HttpErrorResponse } from '@angular/common/http'
import { throwError } from 'rxjs'
export function transformError(error: HttpErrorResponse | string) {
let errorMessage = 'An unknown error has occurred'
if (typeof error === 'string') {
errorMessage = error
} else if (error.error instanceof ErrorEvent) {
errorMessage = `Error! ${error.error.message}`
} else if (error.status) {
errorMessage =
`Request failed with ${error.status} ${error.statusText}`
} else if (error instanceof Error) {
errorMessage = error.message
}
return throwError(errorMessage)
}
auth.service.ts
, implement the login
method:
src/app/auth/auth.service.ts
import * as decode from 'jwt-decode'
import { transformError } from '../common/common'
...
login(email: string, password: string): Observable<void> {
const loginResponse$ = this.authProvider(email, password)
.pipe(
map((value) => {
const token = decode(value.accessToken)
return this.transformJwtToken(token)
}),
tap((status) => this.authStatus$.next(status)),
filter((status: IAuthStatus) => status.isAuthenticated),
flatMap(() => this.getCurrentUser()),
map(user => this.currentUser$.next(user)),
catchError(transformError)
)
loginResponse$.subscribe({
error: err => {
this.logout()
return throwError(err)
},
})
return loginResponse$
}
The login
method encapsulates the correct order of operations by calling the authProvider
with the email
and password
information, then decoding the received JWT, transforming it, and updating authStatus$
. Then getCurrentUser()
is called only if status.isAuthenticated
is true
. Later, currentUser$
is updated and, finally, we catch any errors using our custom transformError
function.
We activate the observable stream by calling subscribe
on it. In the case of an error, we call logout()
to maintain the correct status of our application and bubble up errors to consumers of login
by re-throwing the error using throwError
.
Now, the corresponding logout
function needs to be implemented. Logout is triggered by the Logout button from the application toolbar in the case of a failed login attempt, as shown earlier, or if an unauthorized access attempt is detected. We can detect unauthorized access attempts by using a router auth guard as the user is navigating the application, which is a topic covered later in the chapter.
logout
method:
src/app/auth/auth.service.ts
...
logout(clearToken?: boolean): void {
setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
}
We log out by pushing out the defaultAuthStatus
as the next value in the authStatus$
stream. Note the use of setTimeout
, which allows us to avoid timing issues when core elements of the application are all changing statuses at once.
Think about how the login
method adheres to the Open/Closed principle. The method is open to extension through the abstract functions authProvider
, transformJwtToken
, and getCurrentUser
. By implementing these functions in a derived class, we maintain the ability to externally supply different auth providers without having to modify the login
method. As a result, the implementation of the method remains closed to modification, thus adhering to the Open/Closed principle.
The true value of creating abstract classes is the ability to encapsulate common functionality in an extensible way.
You may ignore the getToken
function for now, as we are not yet caching our JWT. Without caching, the user would have to log in with every page refresh. Let's implement caching next.
We must be able to cache the authentication status of the logged-in user. As mentioned, otherwise, with every page refresh, the user will have to go through the login routine. We need to update AuthService
so that it persists the auth status.
There are three main ways to store data:
cookie
localStorage
sessionStorage
Cookies should not be used to store secure data because they can be sniffed or stolen by bad actors. In addition, cookies can store only 4 KB of data and can be set to expire.
localStorage
and sessionStorage
are similar to each other. They are protected and isolated browser-side stores that allow the storage of larger amounts of data for your application. Unlike cookies, you can't set an expiration date-time on values stored in either store. Values stored in either store survive page reloads and restores, making them better candidates than cookies for caching information.
The major difference between localStorage
and sessionStorage
is that the values are removed when the browser window is closed. In most cases, user logins are cached anywhere from minutes to a month or more depending on your business, so relying on whether the user closes the browser window isn't very useful. Through this process of elimination, I prefer localStorage
because of the isolation it provides and long-term storage capabilities.
JWTs can be encrypted and include a timestamp for expiration. In theory, this counters the weaknesses of both cookies and localStorage
. If implemented correctly, either option should be secure for use with JWTs, but localStorage
is still preferred.
Let's start by implementing a caching service that can abstract away our method of caching. We can then derive from this service to cache our authentication information:
cacheService
that encapsulates the method of caching:
src/app/auth/cache.service.ts
export abstract class CacheService {
protected getItem<T>(key: string): T | null {
const data = localStorage.getItem(key)
if (data != null) {
return JSON.parse(data)
}
return null
}
protected setItem(key: string, data: object | string) {
if (typeof data === 'string') {
localStorage.setItem(key, data)
}
localStorage.setItem(key, JSON.stringify(data))
}
protected removeItem(key: string) {
localStorage.removeItem(key)
}
protected clear() {
localStorage.clear()
}
}
This cache service base class can be used to give caching capabilities to any service. It is not the same as creating a centralized cache service that you inject into another service. By avoiding a centralized value store, we avoid interdependencies between various services.
AuthService
to extend the CacheService
, which will enable us to implement caching of the JWT in the next section:
src/app/auth/auth.service.ts
...
export abstract class AuthService
extends CacheService implements IAuthService {
constructor() {
super()
}
...
}
Note that we must call the constructor of the base class from the derived class's constructor using the super
method.
Let's go over an example of how to use the base class's functionality by caching the value of the authStatus
object:
example
authStatus$ = new BehaviorSubject<IAuthStatus>(
this.getItem('authStatus') ?? defaultAuthStatus
)
constructor() {
super()
this.authStatus$.pipe(
tap(authStatus => this.setItem('authStatus', authStatus))
)
}
The technique demonstrated in the example leverages RxJS observable streams to update the cache whenever the value of authStatus$
changes. You can use this pattern to persist any kind of data without having to litter your business logic with caching code. In this case, we wouldn't need to update the login
function to call setItem
, because it already calls this.authStatus.next
, and we can just tap into the data stream. This helps with staying stateless and avoiding side effects by decoupling functions from each other.
Note that we also initialize the BehaviorSubject
using the getItem
function. Using the nullish coalescing operator, we only use cached data if it is not undefined
or null
. Otherwise, we provide the default value.
You can implement your own custom cache expiration scheme in the setItem
and getItem
functions, or leverage a service created by a third party.
However, for an additional layer of security, we won't cache the authStatus
object. Instead, we will only cache the encoded JWT, which contains just enough information, so we can authenticate requests sent to the server. It is important to understand how token-based authentication works to avoid revealing compromising secrets. Review the JWT life cycle from earlier in this chapter to improve your understanding.
Next, let's cache the token.
Let's update the authentication service so that it can cache the token.
AuthService
to be able to set, get, and clear the token, as shown:
src/app/auth/auth.service.ts
...
protected setToken(jwt: string) {
this.setItem('jwt', jwt)
}
getToken(): string {
return this.getItem('jwt') ?? ''
}
protected clearToken() {
this.removeItem('jwt')
}
clearToken
and setToken
during login
, and clearToken
during logout
, as shown:
src/app/auth/auth.service.ts
...
login(email: string, password: string): Observable<void> {
this.clearToken()
const loginResponse$ = this.authProvider(email, password)
.pipe(
map(value => {
this.setToken(value.accessToken)
const token = decode(value.accessToken)
return this.transformJwtToken(token)
}),
tap((status) => this.authStatus$.next(status)),
...
}
logout(clearToken?: boolean) {
if (clearToken) {
this.clearToken()
}
setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
}
Every subsequent request will contain the JWT in the request header. You should secure every API to check for and validate the token received. For example, if a user wants to access their profile, the AuthService
will validate the token to check whether the user is authenticated or not; however, a further database call will still be required to check whether the user is also authorized to view the data. This ensures an independent confirmation of the user's access to the system and prevents any abuse of an unexpired token.
If an authenticated user makes a call to an API where they don't have the proper authorization, say if a clerk wants to get access to a list of all users, then the AuthService
will return a falsy
status, and the client will receive a 403 Forbidden response, which will be displayed as an error message to the user.
A user can make a request with an expired token; when this happens, a 401 Unauthorized response is sent to the client. As a good UX practice, we should automatically prompt the user to log in again and let them resume their workflow without any data loss.
In summary, true security is achieved with robust server-side implementation. Any client-side implementation is largely there to enable a good UX around good security practices.
Now, let's implement a concrete version of the auth service that we can actually use:
$ npm install fake-jwt-sign
AuthService
:
src/app/auth/auth.inmemory.service.ts
import { AuthService } from './auth.service'
@Injectable()
export class InMemoryAuthService extends AuthService {
constructor() {
super()
console.warn(
"You're using the InMemoryAuthService. Do not use this service in production."
)
}
...
}
authProvider
function that simulates the authentication process, including creating a fake JWT on the fly:
src/app/auth/auth.inmemory.service.ts
import { sign } from 'fake-jwt-sign'//For InMemoryAuthService only
...
protected authProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
email = email.toLowerCase()
if (!email.endsWith('@test.com')) {
return throwError('Failed to login! Email needs to end with @test.com.')
}
const authStatus = {
isAuthenticated: true,
userId: this.defaultUser._id,
userRole: email.includes('cashier')
? Role.Cashier
: email.includes('clerk')
? Role.Clerk
: email.includes('manager')
? Role.Manager
: Role.None,
} as IAuthStatus
this.defaultUser.role = authStatus.userRole
const authResponse = {
accessToken: sign(authStatus, 'secret', {
expiresIn: '1h',
algorithm: 'none',
}),
} as IServerAuthResponse
return of(authResponse)
}
...
The authProvider
implements what would otherwise be a server-side method right in the service, so we can conveniently experiment with the code while fine-tuning our auth workflow. The provider creates and signs a JWT with the temporary fake-jwt-sign
library so that I can also demonstrate how to handle a properly formed JWT.
Do not ship your Angular application with the fake-jwt-sign
dependency, since it is meant to be server-side code.
In contrast, a real auth provider would include a POST
call to a server. See the example code that follows:
example
private exampleAuthProvider(
email: string,
password: string
): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>(
`${environment.baseUrl}/v1/login`,
{ email: email, password: password }
)
}
It is pretty straightforward, since the hard work is done on the server side. This call can also be made to a third-party auth provider, which I cover in the Firebase authentication recipe later in this chapter.
Note that the API version, v1
, in the URL path is defined at the service and not as part of the baseUrl
. This is because each API can change versions independently. Login may remain v1
for a long time, while other APIs may be upgraded to v2
, v3
, and so on.
transformJwtToken
will be trivial, because the login function provides us with a token that adheres to IAuthStatus
:
src/app/auth/auth.inmemory.service.ts
protected transformJwtToken(token: IAuthStatus):
IAuthStatus {
return token
}
getCurrentUser
, which should return some default user:
src/app/auth/auth.inmemory.service.ts
protected getCurrentUser(): Observable<User> {
return of(this.defaultUser)
}
Next, provide a defaultUser
as a private property to the class; what follows is one that I've created.
defaultUser
property to the InMemoryAuthService
class:
src/app/auth/auth.inmemory.service.ts
import { PhoneType, User } from '../user/user/user'
...
private defaultUser = User.Build({
_id: '5da01751da27cc462d265913',
email: 'duluca@gmail.com',
name: { first: 'Doguhan', last: 'Uluca' },
picture: 'https://secure.gravatar.com/avatar/7cbaa9afb5ca78d97f3c689f8ce6c985',
role: Role.Manager,
dateOfBirth: new Date(1980, 1, 1),
userStatus: true,
address: {
line1: '101 Sesame St.',
city: 'Bethesda',
state: 'Maryland',
zip: '20810',
},
level: 2,
phones: [
{
id: 0,
type: PhoneType.Mobile,
digits: '5555550717',
},
],
})
Congratulations, you've implemented a concrete, but still fake, auth service. Now that you have the in-memory auth service in place, be sure to run your Angular application and ensure that there are no errors.
Let's test our auth service by implementing a simple login and logout functionality accessible through the UI.
Before we implement a fully-featured login
component, let's wire up pre-baked login behavior to the Login as manager button we have in the HomeComponent
. We can test the behavior of our auth service before getting into the details of delivering a rich UI component.
Our goal is to simulate logging in as a manager. To accomplish this, we need to hard code an e mail address and a password to log in, and upon successful login, maintain the functionality of navigating to the /manager
route.
Note that on GitHub the code sample for this section resides in a file named home.component.simple.ts
under the folder structure of projects/ch8
. The alternate file exists for reference purposes only, since the code from this section dramatically changes later in the chapter. Ignore the file name difference, as it will not impact your coding for this section.
Let's implement a simple login mechanism:
HomeComponent
, implement a login
function that uses the AuthService
:
src/app/home/home.component.ts
import { AuthService } from '../auth/auth.service'
export class HomeComponent implements OnInit {
constructor(private authService: AuthService) {}
ngOnInit(): void {}
login() {
this.authService.login('manager@test.com', '12345678')
}
}
routerLink
and instead call the login
function:
src/app/home/home.component.ts
template: `
<div fxLayout="column" fxLayoutAlign="center center">
<span class="mat-display-2">Hello, Limoncu!</span>
<button mat-raised-button color="primary" (click)="login()">
Login as Manager
</button>
</div>
`,
On successful login, we need to navigate to the /manager
route. We can verify that we're successfully logged in by listening to the authStatus$
and currentUser$
observables exposed by the AuthService
. If authStatus$.isAuthenticated
is true
and currentUser$._id
is a non-empty string, that means that we have a valid login. We can listen to both observables by using RxJS's combineLatest
operator. Given a valid login condition, we can then use the filter
operator to reactively navigate to the /manager
route.
login()
function to implement the login conditional and upon success, navigate to the /manager
route:
src/app/home/home.component.ts
constructor(
private authService: AuthService,
private router: Router
) {}
login() {
this.authService.login('manager@test.com', '12345678')
combineLatest([
this.authService.authStatus$, this.authService.currentUser$
])
.pipe(
filter(([authStatus, user]) =>
authStatus.isAuthenticated && user?._id !== ''
),
tap(([authStatus, user]) => {
this.router.navigate(['/manager'])
})
)
.subscribe()
}
Note that we subscribe to the combineLatest
operator at the end, which is critical in activating the observable streams. Otherwise, our login action will remain dormant unless some other component subscribes to the stream. You only need to activate a stream once.
login
functionality. Verify that the JWT is created and stored in localStorage
using the Chrome DevTools| Application tab, as shown here:
Figure 8.4: DevTools showing Application Local Storage
You can view Local Storage under the Application tab. Make sure that the URL of your application is highlighted. In step 3, you can see that we have a key named jwt
with a valid-looking token.
Note steps 4 and 5 highlighting two warnings, which advise us not to use the InMemoryAuthService
and the fake-jwt-sign
package in production code.
Use breakpoints to debug and step through the code to get a more concrete understanding of how HomeComponent
, InMemoryAuthService
, and AuthService
work together to log the user in.
When you refresh the page, note that you're still logged in, because we're caching the token in local storage.
Since we're caching the login status, we also need to implement a logout experience to complete the auth workflow.
The logout button on the application toolbar is already wired up to navigate to the logout
component we created before. Let's update this component so it can log the user out when navigated to:
logout
component:
src/app/user/logout/logout.component.ts
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService } from '../../auth/auth.service'
@Component({
selector: 'app-logout',
template: `<p>Logging out...</p>`,
})
export class LogoutComponent implements OnInit {
constructor(private router: Router, private authService: AuthService) {}
ngOnInit() {
this.authService.logout(true)
this.router.navigate(['/'])
}
}
Note that we are explicitly clearing the JWT by passing in true
to the logout
function. After we call logout
, we navigate the user back to the home page.
logout
button.We have nailed a solid login and logout implementation. However, we're not yet done with the fundamentals of our auth workflow.
Next, we need to consider the expiration status of our JWT.
It wouldn't be a great UX if you had to log in to Gmail or Amazon every single time you visited the site. This is why we cache the JWT, but it would be an equally bad UX to keep you logged in forever. A JWT has an expiration date policy, where the provider can select a number of minutes or even months to allow your token to be valid for depending on security needs. The in-memory service creates tokens that expire in one hour, so if a user refreshes their browser window within that frame, we should honor the valid token and let the user continue using the application without asking them to log back in.
On the flip side, if the token is expired, we should automatically navigate the user to the login screen for a smooth UX.
Let's get started:
AuthService
class to implement a function named hasExpiredToken
to check whether the token is expired, and a helper function named getAuthStatusFromToken
to decode the token, as shown:
src/app/auth/auth.service.ts
...
protected hasExpiredToken(): boolean {
const jwt = this.getToken()
if (jwt) {
const payload = decode(jwt) as any
return Date.now() >= payload.exp * 1000
}
return true
}
protected getAuthStatusFromToken(): IAuthStatus {
return this.transformJwtToken(decode(this.getToken()))
}
Keep your code DRY! Update the login()
function to use getAuthStatusFromToken()
instead.
AuthService
to check the status of the token:
src/app/auth/auth.service.ts
...
constructor() {
super()
if (this.hasExpiredToken()) {
this.logout(true)
} else {
this.authStatus$.next(this.getAuthStatusFromToken())
}
}
If the token has expired, we log the user out and clear the token from localStorage
. Otherwise, we decode the token and push the auth status to the data stream.
A corner case to consider here is to also trigger the reloading of the current user in the event of a resumption. We can do this by implementing a new pipe that reloads the current user if activated.
login()
function to a private property named getAndUpdateUserIfAuthenticated
so we can reuse it:
src/app/auth/auth.service.ts
...
@Injectable()
export abstract class AuthService extends CacheService implements IAuthService {
private getAndUpdateUserIfAuthenticated = pipe(
filter((status: IAuthStatus) => status.isAuthenticated),
flatMap(() => this.getCurrentUser()),
map((user: IUser) => this.currentUser$.next(user)),
catchError(transformError)
)
...
login(email: string, password: string): Observable<void> {
this.clearToken()
const loginResponse$ = this.authProvider(email, password)
.pipe(
map((value) => {
this.setToken(value.accessToken)
const token = decode(value.accessToken)
return this.transformJwtToken(token)
}),
tap((status) => this.authStatus$.next(status)),
this.getAndUpdateUserIfAuthenticated
)
...
}
...
}
AuthService
, define an observable property named resumeCurrentUser$
as a fork of authStatus$
, and use the getAndUpdateUserIfAuthenticated
logic:
src/app/auth/auth.service.ts
...
protected readonly resumeCurrentUser$ = this.authStatus$.pipe(
this.getAndUpdateUserIfAuthenticated
)
Once resumeCurrentUser$
is activated and status.isAuthenticated
is true
, then this.getCurrentUser()
will be invoked and currentUser$
will be updated.
AuthService
to activate the pipeline if the token is unexpired:
src/app/auth/auth.service.ts
...
constructor() {
super()
if (this.hasExpiredToken()) {
this.logout(true)
} else {
this.authStatus$.next(this.getAuthStatusFromToken())
// To load user on browser refresh,
// resume pipeline must activate on the next cycle
// Which allows for all services to constructed properly
setTimeout(() => this.resumeCurrentUser$.subscribe(), 0)
}
}
Using the preceding technique, we can retrieve the latest user profile data without having to deal with caching issues.
To experiment with token expiration, I recommend that you create a faster-expiring token in InMemoryAuthService
.
As demonstrated earlier in the caching section, it is possible to cache the user profile data using this.setItem
and the profile data from cache on first launch. This would provide a faster UX and cover cases where users may be offline. After the application launches, you could then asynchronously fetch fresh user data and update currentUser$
when new data comes in. You would need to add additional caching and tweak the getCurrentUser()
logic to get such functionality working. Oh, and you would need a whole lot of testing! It takes a lot of testing to create a high-quality auth experience.
Congratulations, we're done implementing a robust auth workflow! Next, we need to integrate auth with Angular's HTTP client so we can attach the token to the HTTP header of every request.
Implement an HTTP interceptor to inject the JWT into the header of every request sent to the user and gracefully handle authentication failures by asking the user to log back in:
AuthHttpInterceptor
under auth
:
src/app/auth/auth-http-interceptor.ts
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Observable, throwError } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { AuthService } from './auth.service'
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const jwt = this.authService.getToken()
const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } })
return next.handle(authRequest).pipe(
catchError((err, caught) => {
if (err.status === 401) {
this.router.navigate(
['/login'], { queryParams: {
redirectUrl: this.router.routerState.snapshot.url},}
)
}
return throwError(err)
})
)
}
}
Note that AuthService
is leveraged to retrieve the token, and the redirectUrl
is set for the login
component after a 401
error.
app.module.ts
to provide the interceptor:
src/app/app.module.ts
providers: [
...
{
provide: HTTP_INTERCEPTORS,
useClass: AuthHttpInterceptor,
multi: true,
},
],
Figure 8.5: The request header for lemon.svg
In step 4, you can now observe the interceptor in action. The request for the lemon.svg
file has the bearer token in the request header.
Now that we have our auth mechanisms in place, let's take advantage of all the supporting code we have written with dynamic UI components and a conditional navigation system for a role-based UX.
AuthService
provides asynchronous auth status and user information, including a user's name and role. We can use all this information to create a friendly and personalized experience for users. In this next section, we will implement the LoginComponent
so that users can enter their username and password information and attempt a login.
The login
component leverages the AuthService
that we just created and implements validation errors using reactive forms.
Remember that in app.module.ts
we provided AuthService
using the class InMemoryAuthService
. So, during run time, when AuthService
is injected into the login
component, the in-memory service will be the one in use.
The login
component should be designed to be rendered independently of any other component, because during a routing event, if we discover that the user is not properly authenticated or authorized, we will navigate them to this component. We can capture this origination URL as a redirectUrl
so that once a user logs in successfully, we can navigate them back to it.
Let's begin:
SubSink
package.login
in the root of your application with inline styles.login
component:
src/app/app-routing.modules.ts
...
{ path: 'login', component: LoginComponent },
{ path: 'login/:redirectUrl', component: LoginComponent },
...
Remember that the '**'
path must be the last one defined.
login
logic to the one we implemented in HomeComponent
, now implement the LoginComponent
with some styles:Don't forget to import the requisite dependent modules into your Angular application for the upcoming steps. This is intentionally left as an exercise for you to locate and import the missing modules.
src/app/login/login.component.ts
…
import { AuthService } from '../auth/auth.service'
import { Role } from '../auth/role.enum'
@Component({
selector: 'app-login',
templateUrl: 'login.component.html',
styles: [
`
.error {
color: red
}
`,
`
div[fxLayout] {
margin-top: 32px;
}
`,
],
})
export class LoginComponent implements OnInit {
private subs = new SubSink()
loginForm: FormGroup
loginError = ''
redirectUrl: string
constructor(
private formBuilder: FormBuilder,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute
) {
this.subs.sink = route.paramMap.subscribe(
params => (this.redirectUrl =
params.get('redirectUrl') ?? ''
)
)
}
ngOnInit() {
this.authService.logout()
this.buildLoginForm()
}
buildLoginForm() {
this.loginForm = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
]],
})
}
async login(submittedForm: FormGroup) {
this.authService
.login(
submittedForm.value.email,
submittedForm.value.password
)
.pipe(catchError(err => (this.loginError = err)))
this.subs.sink = combineLatest([
this.authService.authStatus$,
this.authService.currentUser$,
])
.pipe(
filter(
([authStatus, user]) =>
authStatus.isAuthenticated && user?._id !== ''
),
tap(([authStatus, user]) => {
this.router.navigate([this.redirectUrl || '/manager'])
})
)
.subscribe()
}
}
We are using SubSink
to manage our subscriptions. We ensure that we are logged out when ngOnInit
is called. We build the reactive form in a standard manner. Finally, the login
method calls this.authService.login
to initiate the login process.
We listen to the authStatus$
and currentUser$
data streams simultaneously using combineLatest
. Every time there's a change in each stream, our pipe gets executed. We filter out unsuccessful login attempts. As the result of a successful login attempt, we leverage the router to navigate an authenticated user to their profile. In the case of an error sent from the server via the service, we assign that error to loginError
.
email
and password
, and if there are any server errors, display them:
Don't forget to import ReactiveFormsModule
in app.modules.ts
.
src/app/login/login.component.html
<div fxLayout="row" fxLayoutAlign="center">
<mat-card fxFlex="400px">
<mat-card-header>
<mat-card-title>
<div class="mat-headline">Hello, Limoncu!</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="loginForm" (ngSubmit)="login(loginForm)" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
<mat-icon>email</mat-icon>
<mat-form-field fxFlex>
<input matInput placeholder="E-mail" aria-label="E- mail" formControlName="email">
<mat-error *ngIf="loginForm.get('email')?.hasError('required')">
E-mail is required
</mat-error>
<mat-error *ngIf="loginForm.get('email')?.hasError('email')">
E-mail is not valid
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
<mat-icon matPrefix>vpn_key</mat-icon>
<mat-form-field fxFlex>
<input matInput placeholder="Password" aria- label="Password" type="password" formControlName="password">
<mat-hint>Minimum 8 characters</mat-hint>
<mat-error *ngIf="loginForm.get('password')?.hasError('required')">
Password is required
</mat-error>
<mat-error *ngIf="loginForm.get('password')?.hasError('minlength')">
Password is at least 8 characters long
</mat-error>
<mat-error *ngIf="loginForm.get('password')?.hasError('maxlength')">
Password cannot be longer than 50 characters
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" class="margin-top">
<div *ngIf="loginError" class="mat-caption error">{{loginError}}</div>
<div class="flex-spacer"></div>
<button mat-raised-button type="submit" color="primary" [disabled]="loginForm.invalid">Login</button>
</div>
</form>
</mat-card-content>
</mat-card>
</div>
The Login button is disabled until the email and password meet client site validation rules. Additionally, <mat-form-field>
will only display one mat-error
at a time, unless you create more space for more errors, so be sure to place your error conditions in the correct order.
Once you're done implementing the login
component, you can now update the home screen to conditionally display or hide the new component we created.
HomeComponent
to clean up the code we added previously, so we can display the LoginComponent
when users land on the home page of the app:
src/app/home/home.component.ts
...
template: `
<div *ngIf="displayLogin">
<app-login></app-login>
</div>
<div *ngIf="!displayLogin">
<span class="mat-display-3">You get a lemon, you get a lemon, you get a lemon...</span>
</div>
`,
})
export class HomeComponent {
displayLogin = true
constructor() {
}
}
Your application should look similar to this screenshot:
Figure 8.6: LemonMart with login
There's still some work to be done in terms of implementing and showing/hiding the sidenav
menu, profile, and logout icons given the user's authentication status.
Conditional navigation is necessary for creating a frustration-free UX. By selectively showing the elements that the user has access to and hiding the ones they don't have access to, we allow the user to confidently navigate through the application.
Let's start by hiding the login
component after a user logs in to the application:
HomeComponent
, inject the AuthService
into the constructor as a public
variable:
src/app/home/home.component.simple.ts
...
import { AuthService } from '../auth/auth.service'
...
export class HomeComponent {
constructor(public authService: AuthService) {}
}
displayLogin
, because we can directly tap into the auth status in the template using the async
pipe.ngIf; else
syntax, along with the async
pipe, as shown here:
src/app/home/home.component.ts
...
template: `
<div *ngIf=
"(authService.authStatus$ | async)?.isAuthenticated; else doLogin">
<div class="mat-display-4">
This is LemonMart! The place where
</div>
<div class="mat-display-4">
You get a lemon, you get a lemon, you get a lemon...
</div>
<div class="mat-display-4">
Everybody gets a lemon.
</div>
</div>
<ng-template #doLogin>
<app-login></app-login>
</ng-template>
`,
Using the async
pipe avoids errors like Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked
. Whenever you see this error, stop using local variables and instead use the async
pipe. It is the reactive thing to do!
AppComponent
, we will follow a similar pattern by injecting AuthService
as a public
variable:
src/app/app.component.ts
import { Component, OnInit } from '@angular/core'
import { AuthService } from './auth/auth.service'
...
export class AppComponent implements OnInit {
constructor(..., public authService: AuthService) {
}
ngOnInit(): void {}
...
}
mat-toolbar
in the template, so that we monitor both authStatus$
and currentUser$
using the async
pipe:
<mat-toolbar ...
*ngIf="{
status: authService.authStatus$ | async,
user: authService.currentUser$ | async
} as auth;">
*ngIf
to hide all buttons meant for logged-in users:
src/app/app.component.ts
<button *ngIf="auth?.status?.isAuthenticated" ... >
Now, when a user is logged out, your toolbar should look all clean, with no buttons, as shown here:
Figure 8.7: The LemonMart toolbar before a user logs in
account_circle
icon in the profile
button if the user has a picture:
src/app/app.component.ts
styles: [
`
.image-cropper {
width: 40px;
height: 40px;
position: relative;
overflow: hidden;
border-radius: 50%;
margin-top: -8px;
}
`],
template: `
...
<button
*ngIf="auth?.status?.isAuthenticated"
mat-mini-fab
routerLink="/user/profile"
matTooltip="Profile"
aria-label="User Profile"
>
<img *ngIf="auth?.user?.picture" class="image-cropper"
[src]="auth?.user?.picture" />
<mat-icon *ngIf="!auth?.user?.picture">account_circle</mat-icon>
</button>
We now have a highly functional toolbar that reacts to the auth status of the application and is additionally able to display information that belongs to the logged-in user.
Before we move on, we need to refactor the validations for LoginComponent
. As we implement more forms in Chapter 11, Recipes – Reusability, Routing, and Caching, you will realize that it gets tedious, fast, to repeatedly type out form validations in either template or reactive forms. Part of the allure of reactive forms is that they are driven by code, so we can easily extract the validations to a shared class, unit test, and reuse them, as follows:
validations.ts
file under the common
folder.src/app/common/validations.ts
import { Validators } from '@angular/forms'
export const EmailValidation = [
Validators.required, Validators.email
]
export const PasswordValidation = [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
]
Depending on your password validation needs, you can use a RegEx
pattern with the Validations.pattern()
function to enforce password complexity rules or leverage the OWASP npm package, owasp-password-strength-test
, to enable pass-phrases, as well as set more flexible password requirements. See the link to the OWASP authentication general guidelines in the Further reading section.
login
component with the new validations:
src/app/login/login.component.ts
import { EmailValidation, PasswordValidation } from '../common/validations'
...
this.loginForm = this.formBuilder.group({
email: ['', EmailValidation],
password: ['', PasswordValidation],
})
Next, let's encapsulate some common UI behavior in an Angular service.
As we start dealing with complicated workflows, such as the auth workflow, it is important to be able to programmatically display a toast notification for the user. In other cases, we may want to ask for a confirmation before executing a destructive action with a more intrusive pop-up notification.
No matter what component library you use, it gets tedious to recode the same boilerplate just to display a quick notification. A UI service can neatly encapsulate a default implementation that can also be customized as needed.
In the UI service, we will implement a showToast
and a showDialog
function that can trigger notifications or prompt users for a decision, in such a manner that we can use it within the code that implements our business logic.
Let's get started:
ui
under common
.showToast
function using MatSnackBar
:Check out the documentation for MatSnackBar
at https://material.angular.io.
Don't forget to update app.module.ts
and material.module.ts
with the various dependencies as they are introduced.
src/app/common/ui.service.ts
@Injectable({
providedIn: 'root',
})
export class UiService {
constructor(private snackBar: MatSnackBar, private dialog: MatDialog) {}
showToast(message: string, action = 'Close', config?: MatSnackBarConfig) {
this.snackBar.open( message,
action,
config || { duration: 7000}
)
}
...
}
For a showDialog
function using MatDialog
, we must implement a basic dialog
component.
Check out the documentation for MatDialog
at https://material.angular.io.
simpleDialog
under the common
folder provided in app.module.ts
with inline templates and styling, skip testing, and a flat folder structure:
app/common/simple-dialog.component.ts
import { Component, Inject } from '@angular/core'
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'
@Component({
// prettier-ignore
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>
<p>{{ data.content }}</p>
</mat-dialog-content>
<mat-dialog-actions>
<span class="flex-spacer"></span>
<button mat-button mat-dialog-close *ngIf="data.cancelText">
{{ data.cancelText }}
</button>
<button mat-button mat-button-raised color="primary" [mat-dialog-close]="true"
cdkFocusInitial>
{{ data.okText }}
</button>
</mat-dialog-actions>
`
})
export class SimpleDialogComponent {
constructor(
public dialogRef: MatDialogRef<SimpleDialogComponent, boolean>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
}
Note that SimpleDialogComponent
should not have an application selector like selector: 'app-simple-dialog'
since we only plan to use it with UiService
. Remove this property from your component.
showDialog
function using MatDialog
to display the SimpleDialogComponent
:
app/common/ui.service.ts
...
showDialog(
title: string,
content: string,
okText = 'OK',
cancelText?: string,
customConfig?: MatDialogConfig
): Observable<boolean> {
const dialogRef = this.dialog.open(
SimpleDialogComponent,
customConfig || {
width: '300px',
data: { title, content, okText, cancelText },
}
)
return dialogRef.afterClosed()
}
ShowDialog
returns an Observable<boolean>
, so you can implement a follow-on action, depending on what selection the user makes. Clicking on OK will return true
, and Cancel will return false
.
In SimpleDialogComponent
, using @Inject
, we're able to use all variables sent by showDialog
to customize the content of the dialog.
app.module.ts
, declare SimpleDialogComponent
as an entry
component:
src/app/app.module.ts
@NgModule({
...
bootstrap: [AppComponent],
entryComponents: [SimpleDialogComponent],
})
Export class AppModule {}
Note that with the Ivy rendering engine, entryComponents
should be unnecessary and is deprecated in Angular 9. However, at the time of publishing, it is still required to declare this component as an entry
component.
login()
function on the LoginComponent
to display a toast message after login:
src/app/login/login.component.ts
import { UiService } from '../common/ui.service'
...
constructor(... , private uiService: UiService)
...
async login(submittedForm: FormGroup) {
...
tap(([authStatus, user]) => {
this.uiService.showToast(
`Welcome ${user.fullName}! Role: ${user.role}`
)
...
})
...
Now, a toast message will appear after a user logs in, as shown:
Figure 8.8: Material snackbar
The snackBar
will either take up the full width of the screen or a portion, depending on the size of the browser.
src/app/login/login.component.ts
this.uiService.showDialog(
`Welcome ${user.fullName}!`, `Role: ${user.role}`
)
Now that you've verified that both showToast
and showDialog
work, which one do you prefer? My rule of thumb is that unless the user is about to take an irreversible action, you should choose toast messages over dialogs, so you don't interrupt the user's workflow.
Next, let's implement an application-wide side navigation experience as an alternative to the toolbar-based navigation we already have, so that users can switch between modules with ease.
Enable mobile-first workflows and provide an easy navigation mechanism to quickly jump to the desired functionality. Using the authentication service, given a user's current role, only display the links for features they can access. We will be implementing the side navigation mock-up as follows:
Figure 8.9: Side navigation mock-up
Let's implement the code for the side navigation as a separate component, so that it is easier to maintain:
NavigationMenu
with inline templates and styles.The side navigation isn't technically required until after a user is logged in. However, in order to be able to launch the side navigation menu from the toolbar, we need to be able to trigger it from AppComponent
. Since this component will be simple, we will eagerly load it. To do this lazily, Angular does have a Dynamic Component Loader pattern, which has a high implementation overhead that will only make sense if multi-hundred kilobyte savings are made.
SideNav
will be triggered from the toolbar, and it comes with a <mat-sidenav-container>
parent container that hosts the SideNav
itself and the content of the application. So, we will need to render all application content by placing the <router-outlet>
inside <mat-sidenav-content>
.
AppComponent
, define some styles that will ensure that the web application will expand to fill the entire page and remain properly scrollable for desktop and mobile scenarios:
src/app/app.component.ts
styles: [
`
.app-container {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.app-is-mobile .app-toolbar {
position: fixed;
z-index: 2;
}
.app-sidenav-container {
flex: 1;
}
.app-is-mobile .app-sidenav-container {
flex: 1 0 auto;
}
mat-sidenav {
width: 200px;
}
.image-cropper {
width: 40px;
height: 40px;
position: relative;
overflow: hidden;
border-radius: 50%;
margin-top: -8px;
}
`,
],
MediaObserver
service from Angular Flex Layout in AppComponent
. Also, implement OnInit
and OnDestory
, initialize SubSink
, and add a Boolean property named opened
:
src/app/app.component.ts
import { MediaObserver } from '@angular/flex-layout'
export class AppComponent implements OnInit, OnDestroy {
private subs = new SubSink()
opened: boolean
constructor(
...
public media: MediaObserver
) {
...
}
ngOnDestroy() {
this.subs.unsubscribe()
}
ngOnInit(): void {
throw new Error('Method not implemented.')
}
}
To automatically determine the open/closed status of the side navigation, we need to monitor the media observer and the auth status. When the user logs in, we would like to show the side navigation, and hide it when the user logs out. We can do this with settings opened
to the value of authStatus$.isAuthenticated
. However, if we only consider isAuthenticated
, and the user is on a mobile device, we will create a less than ideal UX. Watching for the media observer's mediaValue
, we can check to see whether the screen size is set to extra small, or xs
; if so, we can keep the side navigation closed.
ngOnInit
to implement the dynamic side navigation open/closed logic:
src/app/app.component.ts
ngOnInit() {
this.subs.sink = combineLatest([
this.media.asObservable(),
this.authService.authStatus$,
])
.pipe(
tap(([mediaValue, authStatus]) => {
if (!authStatus?.isAuthenticated) {
this.opened = false
} else {
if (mediaValue[0].mqAlias === 'xs') {
this.opened = false
} else {
this.opened = true
}
}
})
)
.subscribe()
}
By monitoring both the media and authStatus$
streams, we can consider unauthenticated scenarios where the side navigation should not be opened even if there's enough screen space.
SideNav
that will slide over the content in mobile or push the content aside in desktop scenarios:
src/app/app.component.ts
...
// prettier-ignore
template: `
<div class="app-container">
<mat-toolbar color="primary" fxLayoutGap="8px"
class="app-toolbar"
[class.app-is-mobile]="media.isActive('xs')"
*ngIf="{
status: authService.authStatus$ | async,
user: authService.currentUser$ | async
} as auth;"
>
<button *ngIf="auth?.status?.isAuthenticated"
mat-icon-button (click)="sidenav.toggle()"
>
<mat-icon>menu</mat-icon>
</button>
...
</mat-toolbar>
<mat-sidenav-container class="app-sidenav-container">
<mat-sidenav #sidenav
[mode]="media.isActive('xs') ? 'over' : 'side'"
[fixedInViewport]="media.isActive('xs')"
fixedTopGap="56" [(opened)]="opened"
>
<app-navigation-menu></app-navigation-menu>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
`,
The preceding template leverages the Angular Flex Layout media observer that was injected earlier for a responsive implementation.
You can use the // prettier-ignore
directive above your template to prevent Prettier from breaking up your template into too many lines, which can hurt readability in certain conditions similar to this one.
We will implement navigational links in NavigationMenuComponent
. The number of links in our application will likely grow over time and be subject to various role-based business rules. Therefore, if we were to implement these links in app.component.ts
, we would risk that file getting too large. In addition, we don't want app.component.ts
to change very often, since changes made there can impact the entire application. It is a good practice to implement the links in a separate component.
NavigationMenuComponent
:
src/app/navigation-menu/navigation-menu.component.ts
...
styles: [
`
.active-link {
font-weight: bold;
border-left: 3px solid green;
}
`,
],
template: `
<mat-nav-list>
<h3 matSubheader>Manager</h3>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/manager/users">
Users
</a>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/manager/receipts">
Receipts
</a>
<h3 matSubheader>Inventory</h3>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/inventory/stockEntry">
Stock Entry
</a>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/inventory/products">
Products
</a>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/inventory/categories">
Categories
</a>
<h3 matSubheader>Clerk</h3>
<a mat-list-item
routerLinkActive="active-link"
routerLink="/pos">
POS
</a>
</mat-nav-list>
`,
...
<mat-nav-list>
is functionally equivalent to <mat-list>
, so you can use the documentation of MatList
for layout purposes. Observe the subheaders
for Manager, Inventory, and Clerk here:
Figure 8.10: The Manager dashboard showing Receipt Lookup on desktop
routerLinkActive="active-link"
highlights the selected Receipts route, as shown in the preceding screenshot.
Additionally, you can see the difference in appearance and behavior on mobile devices as follows:
Figure 8.11: The Manager dashboard showing Receipt Lookup on mobile
Next, let's implement role-based routing.
This is the most elemental and important part of your application. With lazy loading, we have ensured that only the bare minimum number of assets will be loaded to enable a user to log in.
Once a user logs in, they should be routed to the appropriate landing screen as per their user role, so they're not guessing how they need to use the application. For example, a cashier needs to only access the point of sale (POS) to check out customers, so they can automatically be routed to that screen.
The following is a mock-up of the POS screen:
Figure 8.12: A POS screen mock-up
Let's ensure that users get routed to the appropriate page after logging in by updating the LoginComponent
.
Update the login
logic to route per role in the function named homeRoutePerRole
:
app/src/login/login.component.ts
async login(submittedForm: FormGroup) {
...
this.router.navigate([
this.redirectUrl ||
this.homeRoutePerRole(user.role as Role)
])
...
}
private homeRoutePerRole(role: Role) {
switch (role) {
case Role.Cashier:
return '/pos'
case Role.Clerk:
return '/inventory'
case Role.Manager:
return '/manager'
default:
return '/user/profile'
}
}
Similarly, clerks and managers are routed to their landing screens to access the features they need to accomplish their tasks, as shown earlier. Since we have implemented a default manager role, the corresponding landing experience will be launched automatically. The other side of the coin is intentional and unintentional attempts to access routes that a user isn't meant to have access to. In the next section, you will learn about router guards that can help to check authentication and even load requisite data before the form is rendered.
Router guards enable the further decoupling and reuse of logic, and greater control over the component life cycle.
Here are the four major guards you will most likely use:
CanActivate
and CanActivateChild
: Used for checking auth access to a routeCanDeactivate
: Used to ask permission before navigating away from a routeResolve
: Allows the pre-fetching of data from route parametersCanLoad
: Allows custom logic to execute before loading feature module assetsRefer to the following sections to discover how to leverage CanActivate
and CanLoad
. The Resolve
guard will be covered in Chapter 11, Recipes – Reusability, Routing, and Caching.
Auth guards enable a good UX by allowing or disallowing accidental navigation to a feature module or a component before the module has loaded or before any improper data requests have been made to the server. For example, when a manager logs in, they're automatically routed to the /manager/home
path. The browser will cache this URL, and it would be completely plausible for a clerk to accidentally navigate to the same URL. Angular doesn't know whether a particular route is accessible to a user or not and, without an AuthGuard
, it will happily render the manager's home page and trigger server requests that will end up failing.
Regardless of the robustness of your frontend implementation, every REST API you implement should be properly secured server-side.
Let's update the router so that ProfileComponent
can't be activated without an authenticated user and the ManagerModule
won't load unless a manager is logging in using an AuthGuard
:
AuthGuard
service:
src/app/auth/auth-guard.service.ts
import { Injectable } from '@angular/core'
import {
ActivatedRouteSnapshot,
CanActivate,
CanActivateChild,
CanLoad,
Route,
Router,
RouterStateSnapshot,
} from '@angular/router'
import { Observable } from 'rxjs'
import { map, take } from 'rxjs/operators'
import { UiService } from '../common/ui.service'
import { Role } from './auth.enum'
import { AuthService } from './auth.service'
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
constructor(
protected authService: AuthService,
protected router: Router,
private uiService: UiService
) {}
canLoad(route: Route):
boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin()
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin(route)
}
canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin(childRoute)
}
protected checkLogin(route?: ActivatedRouteSnapshot):
Observable<boolean> {
return this.authService.authStatus$.pipe(
map((authStatus) => {
const roleMatch = this.checkRoleMatch(
authStatus.userRole, route
)
const allowLogin = authStatus.isAuthenticated && roleMatch
if (!allowLogin) {
this.showAlert(authStatus.isAuthenticated, roleMatch)
this.router.navigate(['login'], {
queryParams: {
redirectUrl: this.getResolvedUrl(route),
},
})
}
return allowLogin
}),
take(1) // complete the observable for the guard to work
)
}
private checkRoleMatch(
role: Role,
route?: ActivatedRouteSnapshot
) {
if (!route?.data?.expectedRole) {
return true
}
return role === route.data.expectedRole
}
private showAlert(isAuth: boolean, roleMatch: boolean) {
if (!isAuth) {
this.uiService.showToast('You must login to continue')
}
if (!roleMatch) {
this.uiService.showToast(
'You do not have the permissions to view this resource'
)
}
}
getResolvedUrl(route?: ActivatedRouteSnapshot): string {
if (!route) {
return ''
}
return route.pathFromRoot
.map((r) => r.url.map((segment) => segment.toString())
.join('/'))
.join('/')
.replace('//', '/')
}
}
CanLoad
guard to prevent the loading of a lazily loaded module, such as the manager's module:
src/app/app-routing.module.ts
...
{
path: 'manager',
loadChildren: () => import('./manager/manager.module')
.then((m) => m.ManagerModule),
canLoad: [AuthGuard],
},
...
In this instance, when the ManagerModule
is being loaded, AuthGuard
will be activated during the canLoad
event, and the checkLogin
function will verify the authentication status of the user. If the guard returns false
, the module will not be loaded. At this point, we don't have the metadata to check the role of the user.
CanActivate
guard to prevent the activation of individual components, such as the user's profile
:
src/app/user/user-routing.module.ts
...
{
path: 'profile', component: ProfileComponent,
canActivate: [AuthGuard]
},
...
In the case of user-routing.module.ts
, AuthGuard
is activated during the canActivate
event, and the checkLogin
function controls where this route can be navigated to. Since the user is viewing their own profile, there's no need to check the user's role here.
CanActivate
or CanActivateChild
with an expectedRole
property to prevent the activation of components by other users, such as ManagerHomeComponent
:
src/app/mananger/manager-routing.module.ts
...
{
path: 'home',
component: ManagerHomeComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
{
path: 'users',
component: UserManagementComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
{
path: 'receipts',
component: ReceiptLookupComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
...
Inside ManagerModule
, we can verify whether the user is authorized to access a particular route. We can do this by defining some metadata in the route definition, like expectedRole
, which will be passed into the checkLogin
function by the canActivate
event. If a user is authenticated but their role doesn't match Role.Manager
, AuthGuard
will return false
and the navigation will be prevented.
Next, we will go over some techniques to get our tests passing.
We need to provide mocked versions of services like AuthService
or UiService
using the commonTestingProviders
function in common.testing.ts
, using a pattern similar to commonTestingModules
, which was mentioned in Chapter 7, Creating a Router-First Line-of-Business App. This way, we won't have to mock the same objects over and over again.
Let's create the spy objects using the autoSpyObj
function from angular-unit-test-helper
and go over some less obvious changes we need to implement to get our tests passing:
commonTestingProviders
in common.testing.ts
:
src/app/common/common.testing.ts
import { autoSpyObj } from 'angular-unit-test-helper'
export const commonTestingProviders: any[] = [
{ provide: AuthService, useValue: autoSpyObj(AuthService) },
{ provide: UiService, useValue: autoSpyObj(UiService) },
]
MediaObserver
in app.component.spec.ts
and update it to use commonTestingModules
:
src/app/app.component.spec.ts
...
TestBed.configureTestingModule({
imports: commonTestingModules,
providers: commonTestingProviders.concat([
{ provide: MediaObserver, useClass: MediaObserverFake },
...
See how the commonTestingProviders
array is being concatenated with fakes that are specific to app.component.ts
; our new mocks should apply automatically.
LoginComponent
to leverage commonTestingModules
and commonTestingProviders
:
src/app/login/login.component.spec.ts
...
TestBed.configureTestingModule({
imports: commonTestingModules,
providers: commonTestingProviders,
declarations: [LoginComponent],
}).compileComponents()
AuthService
and UiService
.auth.service.spec.ts
, where you do not want to use a test double. Since AuthService
is the class under test, make sure it is configured as follows:
src/app/auth/auth.service.spec.ts
...
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService,
{ provide: UiService, useValue: autoSpyObj(UiService) }],
})
ui.service.spec.ts
with similar considerations.Remember, don't move on until all your tests are passed!
We can leverage our current authentication setup and integrate it with a real authentication service. For this section, you need a free Google and Firebase account. Firebase is Google's comprehensive mobile development platform: https://firebase.google.com. You can create a free account to host your application and leverage the Firebase authentication system.
The Firebase console, found at https://console.firebase.google.com, allows you to manage users and send a password reset email without having to implement a backend for your application. Later on, you can leverage Firebase functions to implement APIs in a serverless manner.
Start by adding your project to Firebase using the Firebase console:
Figure 8.13: The Firebase console
It helps to create a Google Analytics account before attempting this, but it should still work. Once your project is created, you should see your project dashboard:
Figure 8.14: The Firebase project overview
On the left-hand side, marked with step 1, you can see a menu of tools and services that you can add to your project. At the top, marked with step 2, you can quickly jump between your projects. First, you need to add an application to your project.
Your project can include multiple distributions of your application, like web, iOS, and Android versions. In this chapter, we're only interested in adding a web application.
Let's get started:
$ npm install -g firebase-tools
$ firebase login
Make sure your current directory is your project's root folder.
$ firebase init
dist/lemon-mart
or the outputPath
defined in your angular.json
fileThis will create two new files: firebase.json
and .firebaserc
.
$ npx ng build --prod
or
$ npm run build:prod
$ firebase deploy
Your website should be available on a URL similar to https://lemon-mart-007.firebaseapp.com, as shown in the terminal.
Add the .firebase
folder to .gitignore
so you don't check in your cache files. The other two files, firebase.json
and .firebaserc
, are safe to commit.
Optionally, connect a custom domain name that you own to the account using the Firebase console.
Now, let's configure authentication.
In the Firebase console:
Figure 8.15: The Firebase Authentication page
You can now see the user management console:
Figure 8.16: The Firebase user management console
It is fairly straightforward and intuitive to operate, so I will leave the configuration of it as an exercise for you.
Let's start by adding Angular Fire, the official Firebase library for Angular, to our application:
$ npx ng add @angular/fire
Follow Angular Fire's quickstart guide to finish setting up the library with your Angular project, which you can find linked from the readme file on GitHub at https://github.com/angular/angularfire2.
app.module.ts
as per the documentation.environment.ts
files.Note that any information provided in environment.ts
is public information. So, when you place your Firebase API key in this file, it will be publicly available. There's a small chance that another developer could abuse your API key and run up your bill. To protect yourself from any such attack, check out this blog post by paachu: How to secure your Firebase project even when your API key is publicly available at https://medium.com/@impaachu/how-to-secure-your-firebase-project-even-when-your-api-key-is-publicly-available-a462a2a58843.
FirebaseAuthService
:
$ npx ng g s auth/firebaseAuth --lintFix
auth.firebase.service.ts
.{ providedIn: 'root' }
.src/app/auth/auth.firebase.service.ts
import { Injectable } from '@angular/core'
import { AngularFireAuth } from '@angular/fire/auth'
import { User as FirebaseUser } from 'firebase'
import { Observable, Subject } from 'rxjs'
import { map } from 'rxjs/operators'
import { IUser, User } from '../user/user/user'
import { Role } from './auth.enum'
import {
AuthService,
IAuthStatus,
IServerAuthResponse,
defaultAuthStatus,
} from './auth.service'
interface IJwtToken {
email: string
iat: number
exp: number
sub: string
}
@Injectable()
export class FirebaseAuthService extends AuthService {
constructor(private afAuth: AngularFireAuth) {
super()
}
protected authProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
const serverResponse$ = new Subject<IServerAuthResponse>()
this.afAuth.signInWithEmailAndPassword(email, password).then(
(res) => {
const firebaseUser: FirebaseUser | null = res.user
firebaseUser?.getIdToken().then(
(token) => serverResponse$.next(
{ accessToken: token } as IServerAuthResponse
),
(err) => serverResponse$.error(err)
)
},
(err) => serverResponse$.error(err)
)
return serverResponse$
}
protected transformJwtToken(token: IJwtToken): IAuthStatus {
if (!token) {
return defaultAuthStatus
}
return {
isAuthenticated: token.email ? true : false,
userId: token.sub,
userRole: Role.None,
}
}
protected getCurrentUser(): Observable<User> {
return this.afAuth.user.pipe(map(this.transformFirebaseUser))
}
private transformFirebaseUser(firebaseUser: FirebaseUser): User
{
if (!firebaseUser) {
return new User()
}
return User.Build({
name: {
first: firebaseUser?.displayName?.split(' ')[0] ||
'Firebase',
last: firebaseUser?.displayName?.split(' ')[1] || 'User',
},
picture: firebaseUser.photoURL,
email: firebaseUser.email,
_id: firebaseUser.uid,
role: Role.None,
} as IUser)
}
logout() {
if (this.afAuth) {
this.afAuth.signOut()
}
this.clearToken()
this.authStatus$.next(defaultAuthStatus)
}
}
As you can see, we only had to implement the delta between our already established authentication code and Firebase's authentication methods. We didn't have to duplicate any code and we even transformed a Firebase user
object into our application's internal user object.
AuthService
provider in app.module.ts
:
src/app/app.module.ts
{
provide: AuthService,
useClass: FirebaseAuthService,
},
Once you've completed the steps, add a new user from the Firebase authentication console and you should be able to log in using real authentication.
src/app/auth/auth.firebase.service.spec.ts
import { AngularFireAuth } from '@angular/fire/auth'
import { UiService } from '../common/ui.service'
import { FirebaseAuthService } from './auth.firebase.service'
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
FirebaseAuthService,
{ provide: UiService, useValue: autoSpyObj(UiService) },
{ provide: AngularFireAuth,
useValue: autoSpyObj(AngularFireAuth)
},
],
})
Stop! Remove the fake-jwt-sign
package from your project before deploying a real authentication method.
Congratulations, your application is integrated with Firebase! Next, let's cover service factories, which can help you to switch the providers of your abstract classes dynamically.
You can dynamically choose providers during load time, so instead of having to change code to switch between authentication methods, you can parametrize environment variables, so different kinds of builds can have different authentication methods. This is especially useful when writing automated UI tests against your application, where real authentication can be difficult, if not impossible, to deal with.
First, we will create an enum
in environment.ts
to help define our options, and then we will use that enum
to choose an auth provider during our application's bootstrap process.
Let's get started:
enum
called AuthMode
:
src/app/auth/auth.enum.ts
export enum AuthMode {
InMemory = 'In Memory',
CustomServer = 'Custom Server',
Firebase = 'Firebase',
}
authMode
property in environment.ts
:
src/environments/environment.ts
...
authMode: AuthMode.InMemory,
...
src/environments/environment.prod.ts
...
authMode: AuthMode.Firebase,
...
authFactory
function in a new file under auth/auth.factory.ts
:
src/app/auth/auth.factory.ts
export function authFactory(afAuth: AngularFireAuth) {
switch (environment.authMode) {
case AuthMode.InMemory:
return new InMemoryAuthService()
case AuthMode.Firebase:
return new FirebaseAuthService(afAuth)
case AuthMode.CustomServer:
throw new Error('Not yet implemented')
}
}
Note that the factory has to import any dependent service.
AuthService
provider in app.module.ts
to use the factory instead:
src/app/app.module.ts
providers: [
{
provide: AuthService,
useFactory: authFactory,
deps: [AngularFireAuth],
},
Note that you can remove imports of InMemoryAuthService
and FirebaseAuthService
from AppModule
.
With this configuration in place, whenever you build your application for local development, you will be using the in-memory auth service and production (or prod) builds will use the Firebase auth service.
You should now be familiar with how to create high-quality auth experiences. In this chapter, we defined a User object that we can hydrate from or serialize to JSON objects, applying object-oriented class design and TypeScript operators for safe data handling.
We leveraged OOP design principals, using inheritance and abstract classes to implement a base auth service that demonstrates the Open/Closed principle.
We covered the fundamentals of token-based authentication and JWTs so that you don't leak any critical user information. You learned that caching and HTTP interceptors are necessary so that users don't have to input their login information with every request. Following that, we implemented two distinct auth providers, one in-memory and one with Firebase.
We then designed a great conditional navigation experience that you can use in your own applications by copying the base elements to your project and implementing your own auth provider. We created a reusable UI service so that you can conveniently inject alerts into the flow-control logic of your application.
Finally, we covered router guards to prevent users from stumbling onto screens they are not authorized to use, and we reaffirmed the point that the real security of your application should be implemented on the server side. You saw how you can use a factory to dynamically provide different auth providers for different environments.
In the next chapter, we will shift gears a bit and learn about containerization using Docker. Docker allows powerful workflows that can greatly improve development experiences, while allowing you to implement your server configuration as code, putting a final nail in the coffin of the developer's favorite excuse when their software breaks: "But it works on my machine!"
Answer the following questions as best as you can to ensure that you've understood the key concepts from this chapter without Googling. Do you need help answering the questions? See Appendix D, Self-Assessment Answers online at https://static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf or visit https://expertlysimple.io/angular-self-assessment.
AuthService
adheres to the Open/Closed principle.combineLatest
and merge
operators?