DEV Community

Cover image for Building User + Auth Service for Forum-Based Web using Redis (Part 2)
Bervianto Leo Pratama
Bervianto Leo Pratama

Posted on

Building User + Auth Service for Forum-Based Web using Redis (Part 2)

Are you ready to build a Forum-Based Website? This is your chance! Let's go! We will start from User Service and Auth Service which is quite a tight coupling.

Design

Database

We will only have User data to be stored in MongoDB.

User Service Database

Architecture That Need to Consider

We will need Auth Service for Update/Delete User. We use Bearer Token and verify it.

Update/Delete User

User Service Base

Install Dependencies

  1. Please make sure we've already in the app/user-service directory.
  2. Install MongoDB.Driver. We will use this dependency to connect to MongoDB. Using this command to install: dotnet add UserService package MongoDB.Driver --version 2.16.1.
  3. Install Isopoh.Cryptography.Argon2. We will use this to hash the user password. Using this command to install: dotnet add UserService package Isopoh.Cryptography.Argon2 --version 1.1.12
  4. Install Redis.OM. We will use this to connect Redis Stack. dotnet add UserService package Redis.OM --version 0.1.9
  5. Install AutoMapper. Using this commands:

    dotnet add UserService package AutoMapper --version 11.0.1
    dotnet add UserService package AutoMapper.Extensions.Microsoft.DependencyInjection --version 11.0.0
    

Your UserService.csproj will become like this.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="11.0.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
    <PackageReference Include="Isopoh.Cryptography.Argon2" Version="1.1.12" />
    <PackageReference Include="MongoDB.Driver" Version="2.16.1" />
    <PackageReference Include="Redis.OM" Version="0.1.9" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
  </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

Connect User Service with MongoDB

Basically, we will have CRUD functions using MongoDB.

  1. Prepare Settings Model. Create file UserService/Model/ForumApiDatabaseSettings.cs.

    namespace UserService.Model;
    
    public class ForumApiDatabaseSettings
    {
        public string ConnectionString { get; set; } = null!;
        public string DatabaseName { get; set; } = null!;
        public string UsersCollectionName { get; set; } = null!;
    }
    
  2. Add config to appsettings.json. You will need to config

    // ... other config
    "ForumApiDatabase": {
        "ConnectionString": "mongodb://root:secretpass@localhost:27017",
        "DatabaseName": "ForumApi",
        "UsersCollectionName": "Users"
      },
    
  3. Add ForumApiDatabaseSettings for DI at Program.cs.

    // Add services to the container.
    builder.Services.Configure<ForumApiDatabaseSettings>(builder.Configuration.GetSection("ForumApiDatabase"));
    
  4. Create a Users Model to store the data. Add to UserService/Model/Users.cs

    namespace UserService.Model;
    
    using System.ComponentModel.DataAnnotations;
    using System.Text.Json.Serialization;
    
    using MongoDB.Bson.Serialization.Attributes;
    using MongoDB.Bson.Serialization.IdGenerators;
    
    using Redis.OM.Modeling;
    
    [Document(StorageType = StorageType.Json, Prefixes = new[] { "Users" }, IndexName = "users-idx")]
    public class Users
    {
        [BsonId(IdGenerator = typeof(CombGuidGenerator))]
        [RedisIdField]
        public Guid Id { get; set; }
        [Required]
        [Indexed]
        public string? Name { get; set; }
        [Required]
        [Indexed]
        public string? Email { get; set; }
        [Required]
        [JsonIgnore]
        public string? Password { get; set; }
    }
    
  5. Create a Repository to store data in MongoDB.

    a. File UserService/Repository/IUserRepository.cs.

    namespace UserService.Repository;
    
    using UserService.Model;
    
    public interface IUserRepository
    {
        Task<List<Users>> GetUsers();
        Task<Users?> GetUserById(Guid id);
        Task<Users?> GetUserByEmail(string email);
        Task<Users?> NewUser(Users user);
        Task<Users> UpdateUser(Guid id, Users user);
        Task<Users?> UpdateUserPassword(Guid id, Users user);
        Task DeleteUser(Guid id);
    }
    

    b. File UserService/Repository/UserRepository.cs.

    namespace UserService.Repository;
    
    using System.Text.Json;
    
    using Isopoh.Cryptography.Argon2;
    
    using Microsoft.Extensions.Options;
    
    using MongoDB.Driver;
    
    using UserService.Model;
    
    public class UserRepository : IUserRepository
    {
    
        private readonly IMongoCollection<Users> _usersCollection;
        private readonly ILogger<UserRepository> _logger;
    
        public UserRepository(IOptions<ForumApiDatabaseSettings> forumApiDatabaseSettings, ILogger<UserRepository> logger)
        {
            _logger = logger;
            var mongoClient = new MongoClient(forumApiDatabaseSettings.Value.ConnectionString);
            var mongoDatabase = mongoClient.GetDatabase(forumApiDatabaseSettings.Value.DatabaseName);
            _usersCollection = mongoDatabase.GetCollection<Users>(forumApiDatabaseSettings.Value.UsersCollectionName);
        }
    
        public async Task<List<Users>> GetUsers()
        {
            return await _usersCollection.Find(_ => true).ToListAsync();
        }
    
        public async Task<Users?> GetUserById(Guid id)
        {
            return await _usersCollection.Find(x => x.Id == id).FirstOrDefaultAsync();
        }
    
        public async Task<Users?> GetUserByEmail(string email)
        {
            return await _usersCollection.Find(x => x.Email == email).FirstOrDefaultAsync();
        }
    
        public async Task<Users?> NewUser(Users user)
        {
            if (user.Password == null)
            {
                _logger.LogDebug("Didn't provide user Password when Create User. Data: {}", JsonSerializer.Serialize(user));
                return null;
            }
            var hashPassword = Argon2.Hash(user.Password);
            user.Password = hashPassword;
            await _usersCollection.InsertOneAsync(user);
            return user;
        }
    
        public async Task<Users> UpdateUser(Guid id, Users user)
        {
            user.Id = id;
            await _usersCollection.ReplaceOneAsync(x => x.Id == id, user, new ReplaceOptions()
            {
                IsUpsert = false,
            });
            return user;
        }
    
        public async Task<Users?> UpdateUserPassword(Guid id, Users user)
        {
            if (user.Password == null)
            {
                return null;
            }
            user.Id = id;
            var hashPassword = Argon2.Hash(user.Password);
            user.Password = hashPassword;
            await _usersCollection.ReplaceOneAsync(x => x.Id == id, user, new ReplaceOptions()
            {
                IsUpsert = false,
            });
            return user;
        }
    
        public async Task DeleteUser(Guid id)
        {
            await _usersCollection.DeleteOneAsync(x => x.Id == id);
        }
    }
    

    c. Add IUserRepository.cs and UserRepository.cs to DI (Dependency Injection) in Program.cs.

    builder.Services.AddSingleton<IUserRepository, UserRepository>();
    

Create Services to Build the Functional

We will continue the function of User Service. Our function will write/update the cache after creating/updating the user. The read function will look at the cache first, after that fall back to MongoDB.

Initiate the Service

  • File UserService/Service/IUserServices.cs
namespace UserService.Service;

using UserService.Model;

public interface IUserServices
{
    Task<List<Users>> GetUsers();
    Task<Users?> GetUserById(Guid id);
    Task<Users?> GetUserByEmail(string email);
    Task<IResult> NewUser(Users user);
    Task<IResult> UpdateUser(Guid id, UserUpdate user, HttpRequest request);
    Task<IResult> UpdateUserPassword(Guid id, UserPassword user, HttpRequest request);
    Task<IResult> DeleteUser(Guid id, HttpRequest request);
}
Enter fullscreen mode Exit fullscreen mode
  • File UserService/Service/UserServices.cs
namespace UserService.Service;

using System;
using System.Text.Json;
using System.Threading.Tasks;

using Redis.OM;
using Redis.OM.Searching;

using UserService.Model;
using UserService.Repository;

public class UserServices : IUserServices
{
    private readonly IUserRepository _userRepository;
    private readonly RedisCollection<Users> _userCache;
    private readonly RedisConnectionProvider _provider;
    private readonly ILogger<UserServices> _logger;

    public UserServices(IUserRepository userRepository, RedisConnectionProvider provider, ILogger<UserServices> logger)
    {
        _logger = logger;
        _userRepository = userRepository;
        _provider = provider;
        _userCache = (RedisCollection<Users>)provider.RedisCollection<Users>();
    }

    // another code will be here
}
Enter fullscreen mode Exit fullscreen mode

Note: We will consider Auth Service connection using API call after we've done with Auth Service.

Read Function

We will have some functions. There are Read All, Read By Id, Read By Email.

  • For Read All users data, we will directly take from DynamoDB. Add this to UserService/Service/UserServices.cs.
    public async Task<List<Users>> GetUsers()
    {
        return await _userRepository.GetUsers();
    }
Enter fullscreen mode Exit fullscreen mode
  • For Read By Email and Read By Id, we will prioritize cache data, if we don't have data in the Redis, we will continue to check in MongoDB.
    public async Task<Users?> GetUserByEmail(string email)
    {
        var existing = await _userCache.Where(x => x.Email == email).FirstOrDefaultAsync();
        if (existing != null)
        {
            return existing;
        }
        return await _userRepository.GetUserByEmail(email);
    }

    public async Task<Users?> GetUserById(Guid id)
    {
        var existing = await _userCache.Where(x => x.Id == id).FirstOrDefaultAsync();
        if (existing != null)
        {
            return existing;
        }
        return await _userRepository.GetUserById(id);
    }
Enter fullscreen mode Exit fullscreen mode

Create Function

    public async Task<IResult> NewUser(Users user)
    {
        if (user.Email == null)
        {
            _logger.LogDebug("User Didn't Provide Email. Data: {}", JsonSerializer.Serialize(user));
            return Results.BadRequest(new { Message = "Please Provide Email" });
        }
        var existing = await _userRepository.GetUserByEmail(user.Email);
        if (existing != null)
        {
            _logger.LogDebug("Found Existing Email. Data: {}", JsonSerializer.Serialize(user));
            return Results.BadRequest(new { Message = "Users Exists" });
        }
        var createdUser = await _userRepository.NewUser(user);
        if (createdUser == null)
        {
            _logger.LogDebug("Failed To Create User. Data: {}", JsonSerializer.Serialize(user));
            return Results.BadRequest(new { Message = "Failed When Create User" });
        }
        await _userCache.InsertAsync(createdUser);
        return Results.Json(new { Message = "Created", User = createdUser });
    }
Enter fullscreen mode Exit fullscreen mode

Update Function

For update, we will separate to update common details of the User and update the password.

public async Task<IResult> UpdateUser(Guid id, UserUpdate user, HttpRequest request)
    {
        // we will uncomment this later
        // var verifyResult = await verifyUserAccess(request, id);
        // if (verifyResult != null)
        // {
        //     return verifyResult;
        // }
        var existing = await _userRepository.GetUserById(user.Id);
        if (existing == null)
        {
            return Results.NotFound(new { Message = "User Not Found" });
        }
        existing.Name = user.Name;
        var updatedUser = await _userRepository.UpdateUser(id, existing);
        var existingCache = await _userCache.Where(x => x.Id == id).FirstOrDefaultAsync();
        if (existingCache == null)
        {
            await _userCache.InsertAsync(updatedUser);
        }
        else
        {
            existingCache.Name = updatedUser.Name;
            await _userCache.Update(existingCache);
        }
        return Results.Json(new { Message = "Updated", User = existing });
    }

    public async Task<IResult> UpdateUserPassword(Guid id, UserPassword user, HttpRequest request)
    {
        // we will uncomment this later
        // var verifyResult = await verifyUserAccess(request, id);
        // if (verifyResult != null)
        // {
        //     return verifyResult;
        // }
        var existing = await _userRepository.GetUserById(user.Id);
        if (existing == null)
        {
            return Results.NotFound(new { Message = "User Not Found" });
        }
        existing.Password = user.Password;
        var updatedUser = await _userRepository.UpdateUserPassword(id, existing);
        if (updatedUser == null)
        {
            return Results.BadRequest(new { Message = "Failed to Update Password" });
        }
        var existingCache = await _userCache.Where(x => x.Id == id).FirstOrDefaultAsync();
        if (existingCache == null)
        {
            await _userCache.InsertAsync(updatedUser);
        }
        else
        {
            existingCache.Password = updatedUser.Password;
            await _userCache.Update(existingCache);
        }
        return Results.Json(new { Message = "Updated", User = updatedUser });
    }
Enter fullscreen mode Exit fullscreen mode

Delete User

    public async Task<IResult> DeleteUser(Guid id, HttpRequest request)
    {
        // we will uncomment this later
        // var verifyResult = await verifyUserAccess(request, id);
        // if (verifyResult != null)
        // {
        //     return verifyResult;
        // }
        var existing = await _userRepository.GetUserById(id);
        if (existing == null)
        {
            return Results.NotFound(new { Message = "User Not Found" });
        }
        await _userRepository.DeleteUser(id);
        await _userCache.Delete(existing);
        return Results.Json(new { Message = "Deleted" });
    }
Enter fullscreen mode Exit fullscreen mode

Update Minimal API Route

Next, we will update our Program.cs to have API with those functions.

  • Prepare IndexCreationService. Create the file UserService/HostedService/IndexCreationService.
namespace UserService.HostedServices;

using Microsoft.Extensions.Logging;

using Redis.OM;

using UserService.Model;

public class IndexCreationService : IHostedService
{
    private readonly RedisConnectionProvider _provider;
    private readonly ILogger<IndexCreationService> _logger;

    public IndexCreationService(RedisConnectionProvider provider, ILogger<IndexCreationService> logger)
    {
        _provider = provider;
        _logger = logger;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogDebug("Create Index {}", typeof(Users));
        var result = await _provider.Connection.CreateIndexAsync(typeof(Users));
        _logger.LogDebug("Create Index {} Result: {}", typeof(Users), result);

    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Prepare the Model for Request Data.

    • Create File UserService/Model/UserCreation.cs
    namespace UserService.Model;
    
    using System.ComponentModel.DataAnnotations;
    
    public class UserCreation
    {
        [Required]
        public Guid Id { get; set; }
        [Required]
        public string Name { get; set; } = null!;
        [Required]
        public string Email { get; set; } = null!;
        [Required]
        public string Password { get; set; } = null!;
    }
    
    • Create File UserService/Model/UserPassword.cs
    namespace UserService.Model;
    
    using System.ComponentModel.DataAnnotations;
    
    public class UserPassword
    {
        [Required]
        public Guid Id { get; set; }
        [Required]
        public string Password { get; set; } = null!;
    }
    
    • Create File UserService/Model/UserUpdate.cs
    namespace UserService.Model;
    
    using System.ComponentModel.DataAnnotations;
    
    public class UserUpdate
    {
        [Required]
        public Guid Id { get; set; }
        [Required]
        public string Name { get; set; } = null!;
    }
    
    • Create File UserService/Model/ById.cs
    
    namespace UserService.Model;
    
    using System.ComponentModel.DataAnnotations;
    
    class ById
    {
        [Required]
        public Guid Id { get; set; }
    }
    
    • Create File for AutoMapper. Create at UserService/Model/UserProfile.cs
    namespace UserService.Model;
    
    using AutoMapper;
    
    public class UserProfile : Profile
    {
        public UserProfile()
        {
            CreateMap<UserCreation, Users>();
        }
    }
    
  • Update file Program.cs

using AutoMapper;

using Microsoft.AspNetCore.Mvc;

using Redis.OM;

using UserService.HostedServices;
using UserService.Model;
using UserService.Repository;
using UserService.Service;



var builder = WebApplication.CreateBuilder(args);

// ... other codes

// Add Connection to Redis
builder.Services.AddSingleton(new RedisConnectionProvider(builder.Configuration["RedisConnectionString"]));
// Add User Services to DI
builder.Services.AddScoped<IUserServices, UserServices>();
// Add Index Creator
builder.Services.AddHostedService<IndexCreationService>();
// add Automapper
builder.Services.AddAutoMapper(typeof(UserProfile));

// ... other codes

// API Mapper
app.MapGet("/users", async ([FromServices] IUserServices userServices) =>
{
    return await userServices.GetUsers();
})
.WithName("GetUsers");

app.MapGet("/users/{id}", async ([FromServices] IUserServices userServices, Guid id) =>
{
    return await userServices.GetUserById(id);
})
.WithName("GetUserById");

app.MapGet("/userByEmail/{email}", async ([FromServices] IUserServices userServices, string email) =>
{
    return await userServices.GetUserByEmail(email);
})
.WithName("GetUserByEmail");

app.MapPost("/users", async ([FromServices] IUserServices userServices, [FromServices] IMapper mapper, [FromBody] UserCreation userCreation) =>
{
    var user = mapper.Map<Users>(userCreation);
    return await userServices.NewUser(user);
})
.WithName("CreateUser");

app.MapPut("/users", async ([FromServices] IUserServices userServices, [FromBody] UserUpdate user, HttpRequest req) =>
{
    return await userServices.UpdateUser(user.Id, user, req);
})
.WithName("UpdateUser");

app.MapPut("/users/password", async ([FromServices] IUserServices userServices, [FromBody] UserPassword user, HttpRequest req) =>
{
    return await userServices.UpdateUserPassword(user.Id, user, req);
})
.WithName("UpdateUserPassword");

app.MapDelete("/users", async ([FromServices] IUserServices userServices, [FromBody] ById data, HttpRequest req) =>
{
    return await userServices.DeleteUser(data.Id, req);
})
.WithName("DeleteUser");
Enter fullscreen mode Exit fullscreen mode

The User Service ready! Yey! You may try to run the project and call the API using curl, Postman, or other tools. You may check the final result here.

Auth Service

Install Dependencies

  • Install mongodb, redis-om, argon2, jsonwebtoken, and winston.
yarn add argon2@^0.28.5 jsonwebtoken@^8.5.1 mongodb@^4.7.0 redis-om@^0.3.5 winston@^3.8.1
yarn add --dev @types/jsonwebtoken@^8.5.8
Enter fullscreen mode Exit fullscreen mode

Result:

  "dependencies": {
    "argon2": "^0.28.5",
    "express": "^4.18.1",
    "jsonwebtoken": "^8.5.1",
    "mongodb": "^4.7.0",
    "redis-om": "^0.3.5",
    "winston": "^3.8.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.13",
    "@types/jsonwebtoken": "^8.5.8",
    "@types/node": "^14.11.2",
    "@typescript-eslint/eslint-plugin": "^5.29.0",
    "@typescript-eslint/parser": "^5.29.0",
    "eslint": "^8.18.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^4.7.4"
  }
Enter fullscreen mode Exit fullscreen mode

Setup Logger using Winston

  • File src/logger.ts
import { createLogger, format, transports } from "winston";
const { combine, timestamp, printf } = format;

const myFormat = printf(({ level, message, timestamp, ...meta }) => {
  const service = meta.service;
  delete meta.service;
  const otherMeta = JSON.stringify(meta);
  return `[${service}] ${timestamp} [${level}] ${message}. Metadata: ${otherMeta}`;
});

const logger = createLogger({
  level: "info",
  format: combine(timestamp(), myFormat),
  defaultMeta: { service: "auth-service" },
  transports: [new transports.Console()],
});

export default logger;
Enter fullscreen mode Exit fullscreen mode
  • Log each requests (src/index.ts)
import logger from "./logger";

// other codes

app.use((req, res, next) => {
  const requestMeta = {
    body: req.body,
    headers: req.headers,
    ip: req.ip,
    method: req.method,
    url: req.url,
    hostname: req.hostname,
    query: req.query,
    params: req.params,
  };

  logger.info("Getting Request", requestMeta);
  next();
});
Enter fullscreen mode Exit fullscreen mode

Setup Store Token in Redis

  • Setup Redis Client. File src/services/redis.ts.
import { Client } from "redis-om";
const connectionString = process.env.REDIS_CONNECTION_STRING;

export const getClient = async () => {
  return await new Client().open(connectionString);
};
Enter fullscreen mode Exit fullscreen mode
  • File src/entities/token.ts
import { Entity, Schema } from "redis-om";
import { getClient } from "../services/redis";

class Token extends Entity {}

const tokenSchema = new Schema(Token, {
  token: { type: "string" },
});

export const getTokenRepository = async () => {
  const client = await getClient();
  return client.fetchRepository(tokenSchema);
};
Enter fullscreen mode Exit fullscreen mode
  • File src/repositories/user.ts
import { client as mongoClient } from "../services/mongo";

export const getUser = async (email: string) => {
  try {
    await mongoClient.connect();
    const database = mongoClient.db(process.env.MONGO_DB_NAME || "ForumApi");
    const users = database.collection(
      process.env.MONGO_USER_COLLECTION || "Users"
    );
    const query = { Email: email };
    return await users.findOne(query);
  } finally {
    mongoClient.close();
  }
};
Enter fullscreen mode Exit fullscreen mode
  • Setup index (src/index.ts)

import { getTokenRepository } from "./entities/token";

// other codes

async function initDB() {
  const tokenRepository = await getTokenRepository();
  await tokenRepository.createIndex();
}

// other codes

app.listen(port, () => {
  initDB().then(() => {
    logger.info(`NODE_ENV: ${process.env.NODE_ENV}`);
    logger.info(`Server listen on port ${port}`);
  });
});
Enter fullscreen mode Exit fullscreen mode

Main Function of Auth Service

Login

  • Prepase constant data. File src/constants.ts.
const constants = {
  defaultExpired: "6h",
  defaultExpiredRefresh: "24h",
  defaultExpiredSecond: 86400,
};

export default constants;
Enter fullscreen mode Exit fullscreen mode
  • Create Jwt Manager. File src/services/token.ts.
import jwt from "jsonwebtoken";
import constants from "../constants";

const secretToken = process.env.AUTH_SECRET || "auth_secret_123";
const refreshTokenSecret = process.env.REFRESH_SECRET || "refresh_secret_123";

type TokenType = "ACCESS_TOKEN" | "REFRESH_TOKEN";

const getSecret = (tokenType: TokenType) => {
  return tokenType == "ACCESS_TOKEN" ? secretToken : refreshTokenSecret;
};

const getExp = (tokenType: TokenType) => {
  return tokenType == "ACCESS_TOKEN"
    ? constants.defaultExpired
    : constants.defaultExpiredRefresh;
};

export const generateToken = (
  {
    id,
    name,
    email,
  }: {
    id: string;
    name: string;
    email: string;
  },
  tokenType: TokenType
) => {
  const secret = getSecret(tokenType);
  return jwt.sign({ id, name, email }, secret, {
    expiresIn: getExp(tokenType),
  });
};

export const verifyToken = (token: string, tokenType: TokenType) => {
  const secret = getSecret(tokenType);
  return jwt.verify(token, secret);
};

export const decode = (token: string) => {
  return jwt.decode(token);
};
Enter fullscreen mode Exit fullscreen mode
  • Prepare MongoDB Connection. Why we need this? We will read the user data directly to MongoDB. File src/services/mongo.ts
import { MongoClient } from "mongodb";

const connectionString = process.env.MONGO_CONNECTION_STRING;
export const client = new MongoClient(connectionString || "");
Enter fullscreen mode Exit fullscreen mode
  • Prepare the function in src/routes/auth.ts.
import { Router } from "express";
import argon2 from "argon2";
import { JwtPayload } from "jsonwebtoken";
import { getTokenRepository } from "../entities/token";
import { generateToken, verifyToken } from "../services/token";
import { getUser } from "../repositories/user";
import constants from "../constants";
import logger from "../logger";

const failedLoginMessage = {
  message: "Failed to Login",
};

const failedLogoutMessage = {
  message: "Failed to Logout",
};

const commonFailed = {
  message: "Failed",
};

type SuccessLogin = {
  message: string;
  accessToken?: string;
  refreshToken?: string;
};

export const router = Router();

router.post("/login", async (req, res) => {
  const email = req.body.email;
  const password = req.body.password;
  if (!email || !password) {
    res.statusCode = 401;
    res.json(failedLoginMessage);
    return;
  }
  const existingUser = await getUser(email);
  if (existingUser == null) {
    res.statusCode = 401;
    res.json(failedLoginMessage);
    return;
  }
  const existingPass = existingUser.Password;
  try {
    const verify = await argon2.verify(existingPass, password);
    if (!verify) {
      res.statusCode = 401;
      res.json(failedLoginMessage);
      return;
    }
  } catch (err) {
    logger.error("Error when verify token", err);
    res.statusCode = 401;
    res.json(failedLoginMessage);
    return;
  }

  const userID = existingUser._id.toString("hex");
  const payload = {
    id: userID,
    name: existingUser.Name,
    email: existingUser.Email,
  };
  const accessToken = generateToken(payload, "ACCESS_TOKEN");
  const refreshToken = generateToken(payload, "REFRESH_TOKEN");
  const tokenRepository = await getTokenRepository();
  const createdToken = await tokenRepository.createAndSave({
    token: refreshToken,
  });
  await tokenRepository.expire(
    createdToken.entityId,
    constants.defaultExpiredSecond
  );
  const successMessage: SuccessLogin = {
    message: "Success",
  };
  successMessage["accessToken"] = accessToken;
  successMessage["refreshToken"] = refreshToken;
  res.json(successMessage);
});

// other codes
Enter fullscreen mode Exit fullscreen mode
  • Setup auth routes in src/index.ts.
import { router as authRouter } from "./routes/auth";

// other codes
app.use("/auth", authRouter);

Enter fullscreen mode Exit fullscreen mode

Logout

Update src/routes.auth.ts.

router.post("/logout", async (req, res) => {
  const token = req.body.token;
  if (!token) {
    res.statusCode = 400;
    res.json(failedLogoutMessage);
    return;
  }
  const tokenRepository = await getTokenRepository();
  const tokenId = await tokenRepository
    .search()
    .where("token")
    .equals(token)
    .return.firstId();
  if (tokenId == null) {
    res.statusCode = 400;
    res.json(failedLogoutMessage);
    return;
  }
  tokenRepository.remove(tokenId);
  res.json({ message: "Success Logout" });
});
Enter fullscreen mode Exit fullscreen mode

Verify

This function will be needed by User Service. Update at src/routes/auth.ts.

router.post("/verify", async (req, res) => {
  const token = req.body.token;
  if (!token) {
    res.statusCode = 400;
    res.json(commonFailed);
    return;
  }
  try {
    const payload = verifyToken(token, "ACCESS_TOKEN");
    const data = payload as JwtPayload;
    const { id } = data;
    res.json({
      message: "OK",
      id,
    });
    return;
  } catch (err) {
    logger.error("Error when verify token", err);
    res.statusCode = 401;
    res.json(commonFailed);
    return;
  }
});
Enter fullscreen mode Exit fullscreen mode

Refresh

We will use this to get a new accessToken. Update at src/routes/auth.ts.

router.post("/refresh", async (req, res) => {
  const token = req.body.token;
  if (!token) {
    res.statusCode = 400;
    res.json(commonFailed);
    return;
  }
  try {
    const payload = verifyToken(token, "REFRESH_TOKEN");
    const tokenRepository = await getTokenRepository();
    const tokenId = await tokenRepository
      .search()
      .where("token")
      .equals(token)
      .return.firstId();
    if (tokenId == null) {
      res.statusCode = 400;
      res.json(failedLogoutMessage);
      return;
    }
    const data = payload as JwtPayload;
    const { id, name, email } = data;
    res.json({
      accessToken: generateToken({ id, name, email }, "ACCESS_TOKEN"),
    });
    return;
  } catch (err) {
    logger.error("Error when verify token", err);
    res.statusCode = 401;
    res.json(commonFailed);
    return;
  }
});
Enter fullscreen mode Exit fullscreen mode

Now we're ready to integrate with User Service!

Update User Service

We will integrate User Service with Auth Service so our Update/Delete will check the Bearer Token.

  • Create UserService/Service/IAuthService.cs.
namespace UserService.Service;

public interface IAuthService
{
    Task<(bool, Guid)> Verify(string bearerToken);
}
Enter fullscreen mode Exit fullscreen mode
  • Create UserService/Service/AuthService.cs. We will call the AuthService using HttpClient.
using Microsoft.Extensions.Options;

using Newtonsoft.Json;

using UserService.Model;

namespace UserService.Service;

public class AuthService : IAuthService
{
    private readonly HttpClient _httpClient;
    private readonly string _authVerify;
    private readonly ILogger<AuthService> _logger;

    public AuthService(IHttpClientFactory httpClientFactory, IOptions<AuthServiceSettings> authServiceSetting, ILogger<AuthService> logger)
    {
        _httpClient = httpClientFactory.CreateClient();
        _authVerify = authServiceSetting.Value.AuthServiceVerify;
        _logger = logger;
    }

    public async Task<(bool, Guid)> Verify(string bearerToken)
    {
        if (string.IsNullOrEmpty(bearerToken))
        {
            _logger.LogDebug("Getting empty bearer.");
            return (false, Guid.Empty);
        }
        var splitted = bearerToken.Split(' ');
        if (splitted.Length != 2)
        {
            _logger.LogDebug("The Bearer Not Valid.");
            return (false, Guid.Empty);
        }

        var jsonBody = JsonContent.Create(new { token = splitted[1] });

        var response = await _httpClient.PostAsync(_authVerify, jsonBody);

        if (!response.IsSuccessStatusCode)
        {
            _logger.LogDebug("Getting non 200 response from Auth Service. Response: {}", response.StatusCode);
            return (false, Guid.Empty);
        }

        Guid userId = Guid.Empty;

        if (response.Content is object && response.Content.Headers.ContentType != null && response.Content.Headers.ContentType.MediaType == "application/json")
        {
            var contentStream = await response.Content.ReadAsStreamAsync();
            using var streamReader = new StreamReader(contentStream);
            using var jsonReader = new JsonTextReader(streamReader);

            JsonSerializer serializer = new JsonSerializer();

            try
            {
                var responseData = serializer.Deserialize<VerifyId>(jsonReader);
                if (responseData != null)
                {
                    var byteArray = Utils.StringToByteArrayFastest(responseData.Id);
                    userId = new Guid(byteArray);
                }
            }
            catch (JsonReaderException)
            {
                _logger.LogDebug("Invalid JSON from Auth Service.");
            }
        }
        return (true, userId);

    }

    private class VerifyId
    {
        public string Id { get; set; } = null!;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Setup Program.cs to have HttpClient DI and AuthService in DI.
builder.Services.AddHttpClient();
builder.Services.AddTransient<IAuthService, AuthService>();
Enter fullscreen mode Exit fullscreen mode
  • Update UserServices.cs. Don't forget to uncomment some codes from the previous step when we create the User Service.
public class UserServices : IUserServices
{
    private readonly IUserRepository _userRepository;
    private readonly IAuthService _authService;
    private readonly RedisCollection<Users> _userCache;
    private readonly RedisConnectionProvider _provider;
    private readonly ILogger<UserServices> _logger;

    public UserServices(IUserRepository userRepository, IAuthService authService, RedisConnectionProvider provider, ILogger<UserServices> logger)
    {
        _logger = logger;
        _userRepository = userRepository;
        _authService = authService;
        _provider = provider;
        _userCache = (RedisCollection<Users>)provider.RedisCollection<Users>();
    }

   // other codes
   private async Task<IResult?> verifyUserAccess(HttpRequest request, Guid userId)
    {
        var bearerToken = request.Headers.Authorization.ToString();
        var (success, id) = await _authService.Verify(bearerToken);
        if (!success)
        {
            return Results.Unauthorized();
        }
        if (id != userId)
        {
            return Results.Unauthorized();
        }
        return null;
    }

}
Enter fullscreen mode Exit fullscreen mode

Finally!

We've learned to use Redis as Cache Data of User Data and also storing Jwt Token data. If you want to try your APIs, you may use this Postman Collection. You may compare your code with this. Other files that are different are optional to improve access to the codes.

Init User + Auth Module #6

  • [x] MongoDB
  • [x] Redis OM
  • [x] Repository
  • [x] Service
  • [x] Authorization

Redis Feature Highlight

We can easily implement RedisJSON using Redis-OM. For example User data, we can implement to store User data to RedisJSON using [Document(StorageType = StorageType.Json, Prefixes = new[] { "Users" }, IndexName = "users-idx")]. It's also easy for Redis-OM for Node.

import { Entity, Schema } from "redis-om";

class Token extends Entity {}

const tokenSchema = new Schema(Token, {
  token: { type: "string" },
});
Enter fullscreen mode Exit fullscreen mode

How about searching using RediSearch? It's also easy! For example to search token.

const tokenId = await tokenRepository
      .search()
      .where("token")
      .equals(token)
      .return.firstId();
Enter fullscreen mode Exit fullscreen mode

We also can check your data in your Redis server using Redis Insight.

Redis Insight

Repository

forum-api-microservices

Forum API Microservices

Directory Structure

Microservices Development

  • You will need to copy or modify docker-compose.yml to ignore the deployment of microservices.
  • Run Redis & MongoDB using docker compose up -d.
  • Go to the microservice you want to update and read the README.md of each directory to understand how to run them.

Development

  • Build Images of Microservices: docker compose build
  • Run all: docker compose up -d

Software Architecture

Software Architecture

License

MIT

MIT License
Copyright (c) 2022 Bervianto Leo Pratama's Personal Projects

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute,

Additional

  • This article used Redis.OM version 0.1.9, there is a breaking change when using 0.2.0. If you wish to use 0.2.0, you can see this PR (Pull Request) to see the changes. I will use 0.2.0 for Thread Service so that you can know the difference.

Thank you for reading

For the next step, we will learn to build Thread Services. After that, we will learn to build the User Interface (Frontend side). So, let's go!

Thanks GIF

Top comments (0)