TLDR: Explore implementing user authentication with JWT, role-based authorization, GraphQL mutations, client-side login forms, and database role configuration, crucial for secure web application development.
Welcome to our exciting journey of building a full-stack web application using Angular and GraphQL.
In the previous article of this series, we learned how to add the user registration functionality to our app. In this article, we will cover how to implement the login and role-based authorization functionalities in our app.
We will use the JSON Web Token (JWT) for authentication in our app. We will render the app in a way that will allow users to login via a login form on the client side. Upon submission, the client will send the user credentials to the server via a POST request for validation. If the authentication is successful on the server, we will create a JWT with user claims and return it to the client. This JWT will be securely stored in the browser’s local storage and sent back to the server as a Bearer token in the header of each subsequent API request.
Additionally, we’ll enhance security by implementing policy-based authorization to control access to application resources based on predefined policy.
Note: To learn more about JWT, refer to the documentation.
Create a new folder named Dto within the MovieApp project. Inside the Dto folder, add a class file named UserLogin.cs, and then add the following code.
public class UserLogin { [Required] public string Username { get; set; } = string.Empty; [Required] public string Password { get; set; } = string.Empty; }
This class will contain the properties required by a user to log into the app.
Next, create another class file named AuthResponse.cs in the Dto folder and add the following code inside it.
public class AuthResponse { public string ErrorMessage { get; set; } = string.Empty; public string? Token { get; set; } = string.Empty; }
This class is used to return the authentication response to the client.
Now, add a new class file named AuthenticatedUser.cs in the Dto folder and add the following code inside it.
public class AuthenticatedUser { public int UserId { get; set; } public string Username { get; set; } = string.Empty; public string UserTypeName { get; set; } = string.Empty; }
This class is used to represent a user who has been authenticated on the server.
Update the Iuser.cs file by adding the following method declarations.
public interface IUser { // Existing methods. AuthenticatedUser AuthenticateUser(UserLogin loginCredentials); }
Update the UserDataAccessLayer class by implementing the AuthenticateUser method, as shown in the following code example.
public AuthenticatedUser AuthenticateUser(UserLogin loginCredentials) { AuthenticatedUser authenticatedUser = new(); var userDetails = _dbContext.UserMasters .FirstOrDefault(u => u.Username == loginCredentials.Username && u.Password == loginCredentials.Password); if (userDetails != null) { authenticatedUser = new AuthenticatedUser { Username = userDetails.Username, UserId = userDetails.UserId, UserTypeName = userDetails.UserTypeName }; } return authenticatedUser; }
The AuthenticateUser method is used to authenticate a user based on their login credentials. Upon receiving the user credentials, the method verifies whether there exists a corresponding user record in the database. If a match is found, the user will be considered authenticated.
Let’s add the secret key, issuer, and audience details for the JSON web token (JWT) in the appsettings.json file, as shown in the following code example.
"Jwt": { "SecretKey": "7V6WGdWPCO141kPD", "Issuer": "https://localhost:7214/", "Audience": "https://localhost:7214/" },
For JWT encryption, we will use HmacSha256 as our preferred encryption algorithm, which accepts a key size of 128 bits. Ensure that the secret key adheres to this requirement to avoid any potential runtime errors.
To implement user authentication via GraphQL, we’ll start by integrating a GraphQL Server Mutation. Let’s see how to do so.
First, we will add the dependency for the IConfiguration service to the AuthMutationResolver class via dependency injection through its constructor. Refer to the code example.
public class AuthMutationResolver { readonly IUser _userService; readonly IConfiguration _config; public AuthMutationResolver(IConfiguration config, IUser userService) { _config = config; _userService = userService; } // Existing code }
The config object is used to access application configuration settings.
Next, we’ll add the following code in the AuthMutationResolver class.
[GraphQLDescription("Authenticate the user.")] public AuthResponse? UserLogin(UserLogin userDetails) { AuthenticatedUser authenticatedUser = _userService.AuthenticateUser(userDetails); if (!string.IsNullOrEmpty(authenticatedUser.Username)) { string tokenString = GenerateJSONWebToken(authenticatedUser); return new AuthResponse { Token = tokenString }; } else { return null; } } string GenerateJSONWebToken(AuthenticatedUser userInfo) { var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"])); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); List<Claim> userClaims = new() { new Claim(JwtRegisteredClaimNames.Name, userInfo.Username), new Claim(JwtRegisteredClaimNames.Sub, userInfo.UserTypeName), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(ClaimTypes.Role,userInfo.UserTypeName), new Claim("userId", userInfo.UserId.ToString()), }; var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: userClaims, expires: DateTime.Now.AddHours(24), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } }
The UserLogin method is pivotal for user authentication and JWT generation upon successful authentication. Let’s break down its functionality for clarity:
By adhering to these steps, the UserLogin method facilitates user authentication and subsequent JWT creation, while the GenerateJSONWebToken method ensures the integrity and security of the generated token.
Now, add the Policies.cs class file inside the MovieApp\Models folder.
Refer to the following code example.
public static class Policies { public static AuthorizationPolicy AdminPolicy() { return new AuthorizationPolicyBuilder().RequireAuthenticatedUser() .RequireRole(UserRoles.Admin) .Build(); } public static AuthorizationPolicy UserPolicy() { return new AuthorizationPolicyBuilder().RequireAuthenticatedUser() .RequireRole(UserRoles.User) .Build(); } }
The Policies class is a static class that contains two methods:
Both methods return an instance of AuthorizationPolicy, which is built using the AuthorizationPolicyBuilder class. In both methods, RequireAuthenticatedUser is a requirement that the user must be authenticated, and RequireRole is a requirement that the user must be in a specific role.
Now, register and configure the authentication services for the .NET middleware. Add the following code to the Program.cs file.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"])), }; });
We will add the authentication services to the application’s dependency injection container and set the default scheme to JWT Bearer.
Inside the options lambda function, several properties are being set:
Inside the TokenValidationParameters object, several properties are being set:
Next, configure the authorization policy for the application. Refer to the following code example.
builder.Services.AddAuthorization(config => { config.AddPolicy(UserRoles.Admin, Policies.AdminPolicy()); config.AddPolicy(UserRoles.User, Policies.UserPolicy()); });
Refer to the following code example to add the authorization support to the GraphQL server.
builder.Services.AddGraphQLServer() .AddAuthorization() .AddQueryType<MovieQueryResolver>() .AddMutationType<MovieMutationResolver>() .AddTypeExtension<AuthMutationResolver>() .AddFiltering() .AddErrorFilter(error => { return error; });
The AddAuthorization extension method adds authorization support to the GraphQL server. This allows us to protect certain fields or types in the GraphQL schema based on the user’s roles or claims.
Finally, add the following code to the program.cs file.
app.UseAuthentication(); app.UseAuthorization();
These two lines of code are middleware components in the ASP.NET Core pipeline. Middleware is the software assembled into an app pipeline to handle requests and responses. Each component decides whether to pass the request to the next component in the pipeline. They can perform certain actions before and after invoking the next component.
Here’s a breakdown of what each line does:
These two lines should be placed after the app.UseRouting() method before the app.UseEndpoints() method. This order is crucial because middleware in the pipeline matters. The routing middleware needs to come first so that it can match the request to a route, while the endpoints middleware must come last so that it can execute the matched route. By placing the authentication and authorization middleware in between, they can effectively use the route information to perform authentication and authorization.
We will use the [Authorize] attribute to secure our GraphQL server endpoint.
Note: The [Authorize] attribute is available on Microsoft.AspNetCore.Authorization and HotChocolate.AspNetCore.Authorization packages. The packages are different from each other. We will use the attribute provided by the HotChocolate.AspNetCore.Authorization package.
Open the MovieMutationResolver.cs file and add the following import statement at the top.
using HotChocolate.Authorization;
Our application allows only an authenticated user with the Admin role to perform actions such as adding, editing, and deleting the movie data. Therefore, we will annotate the corresponding methods with the [Authorize] attribute and policy, as shown in the following code example.
[Authorize(Policy = UserRoles.Admin)] [GraphQLDescription("Add new movie data.")] public AddMoviePayload AddMovie(Movie movie) { // Existing method definition. } [Authorize(Policy = UserRoles.Admin)] [GraphQLDescription("Edit existing movie data.")] public async Task<AddMoviePayload> EditMovie(Movie movie) { // Existing method definition. } [Authorize(Policy = UserRoles.Admin)] [GraphQLDescription("Delete movie data.")] public async Task<int> DeleteMovie(int movieId) { // Existing method definition. }
With the server configuration complete, let’s transition to the application’s client-side implementation.
To enable user login functionality, add the following GraphQL mutation to the src\app\GraphQL\mutation.ts file.
export const LOGIN = gql` mutation login($loginData: UserLoginInput!) { userLogin(userDetails: $loginData) { errorMessage token } } `;
Create a new file named userLogin.ts in the src\app\models folder and include the following code.
Export interface UserLogin { username: string; password: string; } export interface AuthResponse { token: string; errorMessage: string; } export type LoginType = { userLogin: AuthResponse; };
In the previous code example:
Next, create a new file named user.ts within the models folder and add the following code to it.
export class User { userId: number; firstName: string; lastName: string; username: string; userTypeName: string; isLoggedIn: boolean; constructor() { this.userId = 0; this.firstName = ''; this.lastName = ''; this.username = ''; this.userTypeName = ''; this.isLoggedIn = false; } }
The User class is used to represent a user, containing properties related to the user, all of which are initialized in the constructor.
Create another file named userType.ts in the models folder and add the following code to it.
export enum UserType { Anonymous = 'Unk', Admin = 'Admin', User = 'User', }
In this, the UserType enum defines three constants:
Finally, create a new file named loginForm.ts in the models folder and add the following code to it.
import { FormControl } from '@angular/forms'; export interface LoginForm { username: FormControl<string>; password: FormControl<string>; }
This interface is used to create a strongly typed reactive form to capture user login details.
Navigate to the ClientApp folder and run the following command to generate a service file.
ng g s services\login
Now, add the following code to the login.service.ts file.
import { Injectable } from '@angular/core'; import { LoginType } from '../models/userLogin'; import { Mutation } from 'apollo-angular'; import { LOGIN } from '../GraphQL/mutation'; @Injectable({ providedIn: 'root', }) export class LoginService extends Mutation<LoginType> { document = LOGIN; }
This service is used to perform a GraphQL mutation for user login.
Run the following command to generate a new service file.
ng g s services\subscription
Add the following code to the authentication.service.ts file.
import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { User } from '../models/user'; @Injectable({ providedIn: 'root', }) export class SubscriptionService { userData$ = new BehaviorSubject<User>(new User()); }
This class contains a single property, userData$, which is an instance of BehaviorSubject initialized with a new User object. This property is used to store and update user data. Other parts of the application can subscribe to userData$ to get the current user data and receive notifications of changes.
Run the following command to generate a new service file.
ng g s services\authentication
Next, add the following code to the AuthenticationService class inside the authentication.service.ts file.
export class AuthenticationService { constructor( private readonly apollo: Apollo, private readonly subscriptionService: SubscriptionService, private readonly loginService: LoginService ) {} login(userLoginData: UserLogin) { return this.loginService.mutate({ loginData: userLoginData }).pipe( map((response) => { if (response.data) { const authToken = response.data.userLogin.token; localStorage.setItem('authToken', authToken); this.setUserDetails(authToken); } return response; }) ); } setUserDetails(authToken: string) { if (authToken != null) { const userDetails = new User(); const decodeUserDetails = JSON.parse( window.atob(authToken.split('.')[1]) ); userDetails.userId = decodeUserDetails.userId; userDetails.username = decodeUserDetails.name; userDetails.userTypeName = decodeUserDetails.sub; userDetails.isLoggedIn = true; this.subscriptionService.userData$.next(userDetails); } } logout() { localStorage.clear(); this.resetSubscription(); this.apollo.client.resetStore(); } private resetSubscription() { this.subscriptionService.userData$.next(new User()); } }
In the previous code example:
Run the following command to create the login component.
ng g c components\login
Update the LoginComponent class in the src\app\components\login\login.component.ts file as follows.
export class LoginComponent implements OnDestroy { protected loginForm!: FormGroup; private destroyed$ = new ReplaySubject(1); constructor( private readonly formBuilder: NonNullableFormBuilder, private readonly activatedRoute: ActivatedRoute, private readonly router: Router, private readonly authenticationService: AuthenticationService ) { this.initializeForm(); } private initializeForm(): void { this.loginForm = this.formBuilder.group({ username: this.formBuilder.control('', Validators.required), password: this.formBuilder.control('', Validators.required), }); } protected get loginFormControl() { return this.loginForm.controls; } login() { if (this.loginForm.valid) { this.authenticationService .login(this.loginForm.value as UserLogin) .pipe( switchMap(() => { return this.activatedRoute.queryParams; }), takeUntil(this.destroyed$) ) .subscribe({ next: (params) => { const returnUrl = params['returnUrl'] || '/'; this.router.navigate([returnUrl]); }, error: (error) => { this.loginForm.reset(); this.loginForm.setErrors({ invalidLogin: true, }); console.error('Error occurred while login : ', error); }, }); } } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); } }
This component is responsible for handling user login:
<div class="row justify-content-center"> <div class="col-md-6 col-lg-6 col-sm-12"> <div class="title-container p-2 d-flex align-items-center justify-content-between"> <h2 class="m-0">Login</h2> <div> <span class="mat-h4">New User? </span> <button type="submit" ejs-button cssClass="e-primary" [routerLink]="['/register']" > Register </button> </div> </div> <div class="e-card"> <div class="e-card-header" *ngIf="loginForm.errors"> <div class="e-card-header-caption"> <ejs-message content="Username or Password is incorrect" severity="Error" ></ejs-message> </div> </div> <div class="e-card-content row g-0"> <form [formGroup]="loginForm" (ngSubmit)="login()" novalidate> <div class="e-input-section"> <ejs-textbox placeholder="Username" cssClass="e-outline" floatLabelType="Auto" formControlName="username" ></ejs-textbox> </div> <div *ngIf="loginFormControl.username.touched && loginFormControl.username.errors?.['required']" class="e-error" > Username is required. </div> <div class="e-input-section"> <ejs-textbox type="password" placeholder="Password" cssClass="e-outline" floatLabelType="Auto" formControlName="password" ></ejs-textbox> </div> <div *ngIf="loginFormControl.password.touched && loginFormControl.password.errors?.['required']" class="e-error" > Password is required. </div> <div class="e-card-actions d-flex justify-content-end"> <button type="submit" ejs-button cssClass="e-success">Login</button> </div> </form> </div> </div> </div> </div>
This template code is responsible for displaying a user login form:
Update the NavBarComponent class under src\app\components\nav-bar\nav-bar.component.ts as in the following code example.
export class NavBarComponent implements OnInit, OnDestroy { readonly userType = UserType; authenticatedUser = new User(); private destroyed$ = new ReplaySubject<void>(1); public items: ItemModel[] = [ { text: 'Logout', }, ]; constructor( private readonly router: Router, private readonly subscriptionService: SubscriptionService, private readonly authService: AuthenticationService ) {} ngOnInit(): void { this.subscriptionService.userData$ .pipe(takeUntil(this.destroyed$)) .subscribe({ next: (user: User) => { this.authenticatedUser = user; }, error: (error) => { console.error('An error occurred : ', error); }, }); } clickActionItem(args: MenuEventArgs) { if (args.item.text === 'Logout') { this.logout(); } } private logout() { this.authService.logout(); this.router.navigate(['/login']); } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); } }
In the previous code example:
Now, let’s proceed with updating the HTML file by updating the src\app\components\nav-bar\nav-bar.component.html file as follows.
<ejs-appbar colorMode="Primary"> <button ejs-button iconCss="e-icons e-video e-medium" cssClass="e-inherit" [routerLink]="['/']" > Movie App </button> <div class="e-appbar-spacer"></div> <button *ngIf="!authenticatedUser.isLoggedIn" ejs-button cssClass="e-inherit" [routerLink]="['/login']" [isToggle]="true" > Login </button> <button *ngIf="authenticatedUser.userTypeName == userType.Admin" ejs-button cssClass="e-inherit" [routerLink]="['/admin/movies']" [isToggle]="true" > Admin Panel </button> <ng-container *ngIf="authenticatedUser.isLoggedIn"> <button ejs-dropdownbutton cssClass="e-inherit" iconCss="e-icons e-people" [items]="items" content="{{ authenticatedUser.username }}" (select)="clickActionItem($event)" ></button> </ng-container> </ejs-appbar>
In the previous code example:
Update the AppComponent class located in the src\app\app.component.ts file. We’ve incorporated logic to maintain the user authentication state.
export class AppComponent { constructor(private readonly authService: AuthenticationService) { const authToken = localStorage.getItem('authToken'); if (authToken) { this.authService.setUserDetails(authToken); } } }
This adjustment ensures that if a user has previously logged in and their token is saved in local storage, the application will automatically authenticate the user upon loading.
We will create route guards to restrict unauthorized access to the application routes. There are two types of guards implemented.
Run the following command to create the authentication guard.
ng g g guards\auth
This command will ask, “Which type of guard you would like to create?”. Select the CanActivate option when prompted, then press Enter to create the guard.
Upon successful execution, this command will create a folder named guards and create an auth.guard.ts file under it. Add the following code to this file.
import { CanActivateFn } from '@angular/router'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { SubscriptionService } from '../services/subscription.service'; import { map } from 'rxjs'; export const authGuard: CanActivateFn = (route, state) => { const subscriptionService = inject(SubscriptionService); const router = inject(Router); if (localStorage.getItem('authToken')) { return true; } return subscriptionService.userData$.pipe( map((user) => { if (user.isLoggedIn) { return true; } router.navigate(['/login'], { queryParams: { returnUrl: state.url }, }); return false; }) ); };
In the previous code example:
Run the following command to create the admin-auth guard.
ng g g guards\admin-auth
When prompted with which type of guard you would like to create, select CanActivate, which is similar to the admin guard, and press Enter to create the guard.
Refer to the following code in the src\app\guards\admin-auth.guard.ts file.
import { CanActivateFn } from '@angular/router'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { SubscriptionService } from '../services/subscription.service'; import { map } from 'rxjs'; import { UserType } from '../models/userType'; export const adminAuthGuard: CanActivateFn = (route, state) => { const subscriptionService = inject(SubscriptionService); const router = inject(Router); return subscriptionService.userData$.pipe( map((user) => { if (user.isLoggedIn && user.userTypeName === UserType.Admin) { return true; } router.navigate(['/login'], { queryParams: { returnUrl: state.url }, }); return false; }) ); };
In the previous code example:
Open the src\app\app-routing.module.ts file and update the appRoutes array as follows.
const appRoutes: Routes = [ { path: '', component: HomeComponent, pathMatch: 'full' }, { path: 'filter', component: HomeComponent }, { path: 'login', component: LoginComponent }, { path: 'register', component: UserRegistrationComponent }, { path: 'movies/details/:movieId', component: MovieDetailsComponent }, { path: 'admin/movies', loadChildren: () => import('./admin/admin.module').then((module) => module.AdminModule), canActivate: [adminAuthGuard], }, ];
We have configured the routing path for the login component. The last route configuration uses the loadChildren function to lazily load the AdminModule when the admin/movies path is matched. The canActivate option with the adminAuthGuard means that this route is protected by the adminAuthGuard, which checks that the user is an admin before allowing access to the route.
Currently, the user is being redirected to the home page upon successful registration. However, to provide a better user experience, we will redirect the user to the login page once the registration is successful.
Update the routing URL under the subscribe block of the registerUser function in the UserRegistrationComponent class, as follows.
registerUser(): void { // Existing code if (this.userRegistrationForm.valid) { this.registrationService .mutate({ registrationData: userRegistrationData, }) .pipe(takeUntil(this.destroyed$)) .subscribe({ next: (response) => { // Existing code this.router.navigate(['/login']); // Update this line } else { // Existing code } }, }); } }
If a user registers using the UI, they will be registered with the User role. If we want to add a user with the Admin role, we should add them directly via the database. Open SSMS and run the following SQL query in the MovieDB database.
INSERT INTO UserMaster VALUES('Ankit','Sharma','admin','Admin@123','Male','Admin');
This SQL query inserts a user with the Admin role into the UserMaster table in the MovieDB database.
After executing the previous code examples, we will get the output like in the following image.
For more details, refer to the complete source code for the full-stack web app with Angular and GraphQL on GitHub.
Thanks for reading this article. In it, we learned how to implement the Login functionality and add the role-based authorization feature to our application. We also added route guards to prevent unauthorized access to parts of our application.
In the next article of this series, we will learn how to implement the watchlist feature. It will allow the users to add and remove movies from a watchlist. This will be similar to the Wishlist feature of an e-commerce app.
Whether you’re already a valued Syncfusion user or new to our platform, we extend an invitation to explore our Angular components with the convenience of a free trial. This trial allows you to experience the full potential of our components to enhance your app’s user interface and functionality.
Our dedicated support system is readily available if you need guidance or have any inquiries. Contact us through our support forum, support portal, or feedback portal. Your success is our priority, and we’re always delighted to assist you on your development journey!