Skip to content

Commit

Permalink
✨ Add front-End Auth
Browse files Browse the repository at this point in the history
  • Loading branch information
mahmoud-elgammal committed Mar 3, 2024
1 parent 12824a8 commit b6f12fe
Show file tree
Hide file tree
Showing 54 changed files with 1,777 additions and 702 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ jobs:
with:
node-version: 20
cache: 'npm'
- run: yarb
- run: npm
- run: npm ci
36 changes: 33 additions & 3 deletions apps/api/src/common/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export class RedisService {
private readonly logger = new Logger(RedisService.name);

constructor(
private readonly configService: ConfigService,
private readonly configService: ConfigService,
) {
// make wne init module
this.client = new Redis({
Expand All @@ -35,15 +35,45 @@ export class RedisService {
}

async set(key: string, value: string, expire?: string): Promise<void> {
if(!expire) {

if (!expire) {
this.client.set(key, value);
return;
}

await this.client.setex(key, duration(expire), value);
}

async hset(key: string, value: object, expire?: string): Promise<void> {
await this.client.hset(key, value);

if (!expire) {
this.client.expire(key, duration(expire))
return;
}
}

async scan(pattern: string) {
const keys = [];
let cursor = '0';

do {
const result = await this.client.scan(cursor, 'MATCH', pattern);
cursor = result[0];
keys.push(...result[1]);
} while (cursor !== '0');

return keys;
}

async hget(key): Promise<object | null> {
return this.client.hgetall(key);
}

async hdel(key) {
await this.client.del(key)
}

async increment(key: string): Promise<number> {
return this.client.incr(key);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/interfaces/token.interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { AccessToken, RefreshToken } from './token.interface.d';
*/
export interface BaseToken {
sub: string; // store user identifier here
version: number;
deviceId: string;
}

export interface AccessToken extends BaseToken {}
export interface RefreshToken extends BaseToken {
device: string;
version: number;
}

export interface TokenResponse {
Expand Down
6 changes: 2 additions & 4 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,12 @@ async function bootstrap() {

app.enableCors({
credentials: true, // Allow credentials (cookies, HTTP authentication) to be sent cross-origin
origin: ['*'], // Whitelist specific origins
origin: 'http://localhost:3000', // Whitelist specific origins
methods: ['GET', 'POST', 'PUT', 'DELETE'], // Specify the allowed HTTP methods
allowedHeaders: ['Content-Type', 'Authorization'], // Specify the allowed headers
exposedHeaders: ['Authorization'], // Specify headers exposed to the browser
maxAge: 3600, // Configure the maximum age (in seconds) of the preflight request
});

const config: ConfigService = app.get(ConfigService);
const config: ConfigService = app.get(ConfigService); // install helmet
const logger = app.get(Logger);

app.useLogger(app.get(Logger));
Expand Down
48 changes: 42 additions & 6 deletions apps/api/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('register')
async register(@Body() registerDTO: RegisterDTO, @Res() res: Response) {
const tokens = await this.authService.register(registerDTO);
async register(@Body() registerDTO: RegisterDTO, @Res() res: Response, @IpAddress() ip: string, @Headers('User-Agent') ua: string) {
const uaParsed = new UAParser(ua);
const location = geoip.lookup(ip);
const tokens = await this.authService.register(registerDTO, ip, location, uaParsed);
try {
await res.cookie('access_token', tokens.accessToken, {
expires: new Date(Date.now() + 30 * 24 * 3600000),
Expand All @@ -44,8 +46,12 @@ export class AuthController {

@Post('login')
@UseLocalAuthGuard()
async login(@Req() req: Request, @Res() res: Response) {
const tokens = await this.authService.login(req.user);;
async login(@Req() req: Request, @Res() res: Response, @IpAddress() ip: string, @Headers('User-Agent') ua: string) {
const uaParsed = new UAParser(ua);
const location = geoip.lookup(ip);

const tokens = await this.authService.login(req.user, ip, location, uaParsed);

try {
await res.cookie('access_token', tokens.accessToken, {
expires: new Date(Date.now() + 30 * 24 * 3600000),
Expand All @@ -60,6 +66,7 @@ export class AuthController {
secure: true,
sameSite: 'strict',
});

res.status(200).json({ status: 'ok', msg: 'USER_LOGIN_SUCCESSFULLY' });
} catch (err) {
this.logger.warn(err);
Expand All @@ -69,8 +76,11 @@ export class AuthController {

@Get('refresh')
@UseRefreshTokenGuard()
async generateToken(@Req() req: Request, @Res() res: Response) {
const tokens = await this.authService.refreshToken(req.user);
async generateToken(@Req() req: any, @Res() res: Response, @IpAddress() ip: string, @Headers('User-Agent') ua: string) {
const uaParsed = new UAParser(ua);
const location = geoip.lookup(ip);

const tokens = await this.authService.refreshToken(req.user.user, req.user, ip, location, uaParsed);

await res.cookie('access_token', tokens.accessToken, {
expires: new Date(Date.now() + 30 * 24 * 3600000),
Expand Down Expand Up @@ -109,4 +119,30 @@ export class AuthController {
const location = geoip.lookup(ip);
return this.authService.forgetPassword(forgetPasswordDTO.email, ip, location, uaParsed);
}

@Get('logout')
@UseRefreshTokenGuard()
async logout(@Req() req: any,@Res() res: Response) {
await res.clearCookie('access_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});

await res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});

await this.authService.logout(req.user);

res.json({ok: true, message: 'Session has deleted'})
}

@Get('sessions')
@UseAccessTokenGuard()
async getSessions(@Req() req: Request) {
return await this.authService.getSessions(req.user);
}
}
43 changes: 35 additions & 8 deletions apps/api/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { MailService } from './../mail/mail.service';
import { Injectable, Logger } from '@nestjs/common';
import { TokenService } from './token.service';
import { UserService } from './../users/users.service';
import { TokenResponse, AccessToken } from 'src/interfaces/token.interface';
import { TokenResponse, AccessToken, RefreshToken } from 'src/interfaces/token.interface';
import { RegisterDTO } from './dto/register.dto';
import { UserDocument } from '../users/models/users.model';

import { randomBytes } from 'crypto';
import { VerifyEmailDTO } from './dto/verify-email.dto';
import { UAParserInstance } from 'ua-parser-js';
import * as geoip from 'geoip-lite';
import { BadRequestException } from 'src/exceptions';
import * as uuid from 'uuid';

const generateVerificationCode = () => {
return randomBytes(6).toString('hex').toUpperCase(); // Adjust the code length as needed
Expand All @@ -24,15 +26,17 @@ export class AuthService {
private tokenService: TokenService,
private mailService: MailService,
private readonly redisService: RedisService,
) {}
) { }
/**
* TODO
* forget password
*/

async register(registerDTO: RegisterDTO): Promise<TokenResponse> {
async register(registerDTO: RegisterDTO, ip: string, location: geoip.Lookup, ua: UAParserInstance): Promise<TokenResponse> {
const user = await this.userService.create(registerDTO);
const tokens = this.tokenService.generateTokens(user);
const device = ua.getDevice().type || 'Desktop';
const deviceId = generateVerificationCode();
const tokens = this.tokenService.generateTokens(user, 0, deviceId, device, location?.country && `${location.country}, ${location.city}`);
const code = generateVerificationCode();

const key = `verification:${user.email}`;
Expand Down Expand Up @@ -66,8 +70,10 @@ export class AuthService {
return false;
}

login(user: UserDocument): TokenResponse {
const tokens = this.tokenService.generateTokens(user);
login(user: UserDocument, ip: string, location: geoip.Lookup, ua: UAParserInstance): TokenResponse {
const device = ua.getDevice().type || 'Desktop';
const deviceId = generateVerificationCode();
const tokens = this.tokenService.generateTokens(user, 0, deviceId, device, location?.country && `${location.country}, ${location.city}`);
// if (!user.emails.find((i) => i.emailSafe === emailSafe)?.isVerified)
// throw new UnauthorizedException(UNVERIFIED_EMAIL);
return tokens;
Expand Down Expand Up @@ -127,11 +133,27 @@ export class AuthService {
}
}

refreshToken(user: UserDocument): TokenResponse {
const tokens = this.tokenService.generateTokens(user);
async refreshToken(
user: UserDocument,
oldToken: RefreshToken,
ip: string,
location: geoip.Lookup,
ua: UAParserInstance,
): Promise<TokenResponse> {
const device = ua.getDevice().type || 'Desktop';

const session = await this.tokenService.getSession(user._id, oldToken.deviceId);
this.logger.log(session);
if (!session) throw new BadRequestException()

const tokens = this.tokenService.generateTokens(user, ++oldToken.version, oldToken.deviceId, device, location?.country && `${location.country}, ${location.city}`);
return tokens;
}

async logout(session) {
return await this.tokenService.deleteSession(session.user._id, session.deviceId)
}

current(payload: AccessToken) {
const user = this.userService.findById(payload.sub);
return user;
Expand All @@ -146,4 +168,9 @@ export class AuthService {
const key = `password-reset:${user._id}`;
await this.redisService.delete(key);
}

async getSessions(user: any) {
const sessions = await this.tokenService.getSessions(user._id, user.deviceId);
return sessions
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access_toke
}

async validate(payload: AccessToken) {
const user = await this.userService.findById(payload.sub);
const user: any = await this.userService.findById(payload.sub);
user.deviceId = payload.deviceId;
return user;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'refresh-to
// If a user ID is present in the payload, validate and return the user
const user = await this.userService.findById(payload.sub);
if (user) {
return user;
return {...payload, user};
}
}

Expand Down
51 changes: 41 additions & 10 deletions apps/api/src/modules/auth/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,47 @@ import {
TokenResponse,
} from 'src/interfaces/token.interface';
import { UserDocument } from '../users/models/users.model';
import { RedisService } from 'src/common/redis/redis.service';

const getLastKey = (key: string) => key.substring(key.lastIndexOf(':')+1, key.length)

@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
private readonly redisService: RedisService
) { }

generateAccessToken(user: UserDocument): string {
generateAccessToken(user: UserDocument, deviceId: string): string {
const secret = this.configService.get('ACCESS_TOKEN_SECRET');
const expiresIn = this.configService.get('ACCESS_TOKEN_EXPIRATION'); // Access token expiration time, adjust as needed

const payload: AccessToken = {
sub: user._id.toHexString(),
version: 0,
deviceId,
};

return this.jwtService.sign(payload, { expiresIn, secret });
}

generateRefreshToken(user: UserDocument): string {
generateRefreshToken(user: UserDocument, version: number, deviceId: string, device: string, location): string {
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
const expiresIn = this.configService.get('ACCESS_TOKEN_EXPIRATION'); // Access token expiration time, adjust as needed

const payload: RefreshToken = {
sub: user._id.toHexString(),
version: 0,
device: 'xx-xx-xx',
version,
deviceId,
};

this.storeSession(user._id, deviceId, device, location);
return this.jwtService.sign(payload, { expiresIn, secret });
}

generateTokens(user: UserDocument): TokenResponse {
generateTokens(user: UserDocument, version: number, deviceId: string, device: string, location: string): TokenResponse {
const tokens: TokenResponse = {
accessToken: this.generateAccessToken(user),
refreshToken: this.generateRefreshToken(user),
accessToken: this.generateAccessToken(user, deviceId),
refreshToken: this.generateRefreshToken(user, version, deviceId, device, location),
};
return tokens;
}
Expand All @@ -56,4 +61,30 @@ export class TokenService {
const secret = this.configService.get('ACCESS_TOKEN_SECRET');
return this.jwtService.verify(token, secret);
}

async storeSession(userId, deviceId, device, location) {
const key = `token:${userId}:${deviceId}`;
await this.redisService.hset(key, { device, location, lastActive: new Date() }, this.configService.get('REFRESH_TOKEN_EXPIRATION'));
console.log({ device, location, lastActive: new Date() })
}

async getSession(userId, deviceId) {
const key = `token:${userId}:${deviceId}`;
return await this.redisService.hget(key);
}

async getSessions(userId, deviceId) {
const key = `token:${userId}:*`;
const keys = await this.redisService.scan(key);
// const sessions = []

const sessions = await Promise.all(keys.map(key => this.redisService.hget(key)));
return sessions.map((session, i) => ({...session, isCurrent: getLastKey(keys[i]) === deviceId}))
}

async deleteSession(userId, deviceId) {
const key = `token:${userId}:${deviceId}`;
await this.redisService.hdel(key)
return true;
}
}
Loading

0 comments on commit b6f12fe

Please sign in to comment.