Secure Authentication with JWT and Two-factor authentication in Angular & Node.js
In this article, our main focus will be on creating Angular authentication using JWT Tokens and 2FA. We'll use Angular for the client-side and Node.js for the server-side implementation.
First, let's create a new app with initial routing configuration
ng new ng-auth-app --style=scss --routing
Next, generate the Login component ng g c components/login
Let's add logic to the Login component
export class LoginComponent {
loginForm!: FormGroup;
constructor(private formBuilder: FormBuilder) {}
ngOnInit() {
this.loginForm = this.formBuilder.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
}
onSubmit() {
console.log(this.loginForm.value);
}
}
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div>
<label for="username">Username:</label>
<input id="username" formControlName="username" type="text" />
<div
*ngIf="
loginForm.controls['username'].touched &&
loginForm.controls['username'].errors
"
></div>
</div>
<div>
<label for="password">Password:</label>
<input id="password" formControlName="password" type="password" />
<div
*ngIf="
loginForm.controls['username'].touched &&
loginForm.controls['password'].errors
"
></div>
</div>
<button type="submit">Login</button>
</form>
and run ng serve -o
Also, we'll apply styling to our form
form {
display: flex;
flex-direction: column;
width: 300px;
margin: 0 auto;
}
input {
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
}
button {
padding: 10px;
border-radius: 5px;
border: none;
background-color: #007bff;
color: white;
cursor: pointer;
}
and save the changes:
Now let's create our service for login
export class LoginService {
private apiUrl = environment.apiUrl;
constructor(private http: HttpClient) {}
login(user: { username: string; password: string }) {
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, {
username: user.username,
password: user.password,
});
}
}
and change Submit method in LoginComponent
this.loginService.login(this.loginForm.value).subscribe((res: LoginResponse) => {
if (res.code === 200) {
localStorage.setItem('token', res.token);
this.router.navigate(['/success']);
} else {
console.error(res.message);
}
});
It's time to proceed with the server-side implementation. Let's create a new file named server.js and add code
const express = require("express");
const bodyParser = require("body-parser");
const cors = require("cors");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const app = express();
const cookieParser = require("cookie-parser");
app.use(cookieParser());
app.use(cors());
app.use(bodyParser.json());
app.post("/login", (req, res) => {
const { username, password } = req.body;
if (username === "admin" && password === "admin") {
const token = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "1h",
});
res.json({ code: 200, message: "Login successful", token });
} else {
res.json({ code: 401, message: "Invalid username or password" });
}
});
app.listen(3000, () => console.log("Server is running on port 3000"));
To generate a JWT token, you need to install the jsonwebtoken package
npm install jsonwebtoken
and npm install cors
to enable CORS. It's also good practice to create a .env
file (a configuration file where we can store environment-specific variables) and store our JWT_SECRET there.
JWT_SECRET=your_secret_key
After a successful login attempt, we'll be routed to the Success component and receive the token
Although storing JWT tokens in local storage can be risky and it's recommended to use HttpOnly cookies for token storage, for the purpose of our learning exercise, we'll proceed with this approach.
Now it's time to add two-factor authentication for our loginComponent. We'll use 2FA with SMS using Twilio.
First we need to install twilio npm install twilio
and store the data from twilio account in our .env file
JWT_SECRET=your_secret_key
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTH_TOKEN=your_twilio_aut_toke
TWILIO_PHONE_NUMBER=your_phone_number
These variables can be accessed in our code using process.env.VARIABLE_NAME
Next, we initialize the Twilio client using our account SID and authentication
const client = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
create the POST request that sends sms message
server.post('/send-sms', (req, res) => {
const { phoneNumber, message } = req.body;
client.messages
.create({
body: message,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber,
})
.then((message) => res.status(200).json({ message: 'SMS sent successfully' }))
.catch((err) => res.status(500).json({ error: err.message }));
});
and additionally, we need to create a method send2FACodeSMS
in our service
private send2FACodeSMS(code: number) {
const phoneNumber = environment.phoneNumber;
const message = `Your 2FA code is ${code}`;
this.http.post(`${this.apiUrl}/send-sms`, { phoneNumber, message }).subscribe();
}
and call it in the tap operator in our login
method for cleaner code
login(user: { username: string; password: string }) {
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, user).pipe(
tap((res) => {
this.send2FACodeSMS(res.code);
})
)
}
The final step involves adding verification handling in the LoginComponent
to ensure a secure login process.
onSubmit() {
this.loginService
.login(this.loginForm.value)
.subscribe((res: LoginResponse) => {
if (res && res.token) {
// Store the token temporarily
localStorage.setItem('tempToken', res.token);
// Prompt the user to enter the 2FA code
const code = Number(window.prompt('Please enter your 2FA code'));
// Verify the 2FA code
if (code === res.twoFAcode) {
localStorage.setItem('token', res.token);
this.router.navigate(['/success']);
} else {
localStorage.removeItem('tempToken');
alert('Invalid 2FA code. Please try again.');
}So, now after login attemp we will be asked about the code that is sent to defined phone number
}
});
}
After attempting to log in, we will be prompted to enter the code sent to our registered phone number
Once entered, we'll be successfully logged in 🙂
One more thing remains to be done. Storing tokens in localStorage is not the best idea due to potential security risks, so we will change our app to store the token in HTTP-only cookies. In our POST request when user logs in successfully, we should set a cookie with the JWT token
res.cookie("token", token, { httpOnly: true });
In LoginService we need to make sure that our HTTP requests include credentials so that cookies can be sent and received
login(user: { username: string; password: string }) {
return this.http
.post<LoginResponse>(`${this.apiUrl}/login`, user, {withCredentials: true})
.pipe(
tap((res) => {
this.send2FACodeSMS(res.twoFAcode);
})
);
}
And finally, in our LoginComponent
, we no longer need to manually store the token in local storage because the browser will automatically send the cookie with each request
this.loginService
.login(this.loginForm.value)
.subscribe((res: LoginResponse) => {
if (res && res.token) {
//localStorage.setItem('tempToken', res.token); // Remove this line
// Prompt the user to enter the 2FA code
const code = Number(window.prompt('Please enter your 2FA code'));
// Verify the 2FA code
if (code === res.twoFAcode) {
//localStorage.setItem('token', res.token); // Remove this line
this.router.navigate(['/success']);
} else {
//localStorage.removeItem('tempToken'); // Remove this line
alert('Invalid 2FA code. Please try again.');
}
}
});
And with that, we're all set 👍
In summary, combining JWT and Two-factor authentication provides solid security measures, ensuring a safe and reliable authentication process.