Skip to content

Commit

Permalink
docs: add impl details
Browse files Browse the repository at this point in the history
  • Loading branch information
LwveMike committed Jun 11, 2024
1 parent 72b82d9 commit d550f3f
Show file tree
Hide file tree
Showing 20 changed files with 914 additions and 83 deletions.
138 changes: 138 additions & 0 deletions apps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# NFA Authentication

## Authentication Flow description

### Preface

The authentication flow is based on 2 jwt tokens that will be saved in cookies:

- Access token:
- Should be short lived.
- Will contain non-sensitive information that the frontend can decode.
- Will be signed with the private key of the access token.
- Will be used to authenticate the user.

- Refresh token:
- Should be long lived.
- Will contain information that will link between the browser session to the session in the database.
- Will be signed with the private key of the refresh token.
- Will be used to refresh the access token.

### How it will work

By our norms, we will consider an authenticated request to the API if the user:

- Sends both `access-token` and `refresh-token` and they are both valid.
- Sends an expired `access-token` and a valid `refresh-token`:
- The `access-token` will be refreshed and the `refresh-token` will be rotated [ The rotation of the `refresh-token` is not yet decided ].

There will also be endpoints that doesn't require authentication.

The backend should clean any inconsistencies with the cookies: If the `access-token` is expired, this means someone changed the lifetime of the cookie on the client side, this means the backend should delete the cookie from the response. And others such cases.

The frontend should implement a composables that will handle the authentication flow based on the access token.

**Cookies**
The only difference between the `cookie options` for `access-token` and `refresh-token` is the `httpOnly` option. Which should be set to `true` for the `refresh-token` and `false` for the `access-token`. This means that the `access-token` can be read by the `javascript` code in the browser, but the `refresh-token` is protected.

The expires of the cookies should be calculated based on the lifetime of the token.

Also the remaining options for cookies should be set to the most strictest mode.

**Database**
Session table should look like this:

- id: Primary Key
- userId: Foreign Key to the user table
- last_accessed_at: Last time the session was accessed
- defaults to the current time ( used when the session is created )
- expires_at: Expiration time of the session
- defaults to the current time + the lifetime of the token
- device: Device that the session was created on
- defaults to 'unknown'
- os: Operating system of the device
- defaults to 'unknown'
- ip: IP address of the device
- defaults to null

**Role**
Role table should look like this:

## Requirements

### NFA initialization

We should create the keys when NFA is deployed on the clients' servers.

The way I generated them in the investigation phase was:

```bash
openssl ecparam -name prime256v1 -genkey -noout -out "$TOKEN_PREFIX-priv-key.pem"
openssl ec -in "$TOKEN_PREFIX-priv-key.pem" -pubout > "$TOKEN_PREFIX-pub-key.pem"
```

Where `$TOKEN_PREFIX` is the type of the token, eg. `access-token`.

If the person that will be doing this task, has a better way to do it, please let me know ( because we should change some things in the way the tokens are signed ).

The paths of the keys generated should be added to config.

### Config

```json
{
// Path to public key for access token
"jwt.access-token.pub.key": "/path/to/access-token/public.key",
// Path to private key for access token
"jwt.access-token.priv.key": "/path/to/access-token/public.key",
/* Expiry should be represented in seconds, because we will do some calculations on it, for cookies and database time.
*/
// Other formats supported: Eg: 60, "2 days", "10h", "7d"
"jwt.access-token.expiry": 3600 // 1 hour,

// Path to public key for refresh token
"jwt.refresh-token.pub.key": "/path/to/refresh-token/public.key",
// Path to private key for refresh token
"jwt.refresh-token.priv.key": "/path/to/refresh-token/public.key",
// Same format as jwt.access-token.expiry
"jwt.refresh-token.expiry": 1209600 // 2 weeks
}
```

## Implementation

### NAPID

**ConfigService**
The service should be able to read the config file that is the `node-addon` from `c++` and from `.env` file located in `web`.

This service should also validate the values with `zod` and read the keys needed for `access-token` and `refresh-token`.

**RequestMetaService**
This service should have an internal private `WeakMap` that will use the `req` object as the key and decoded `access-token` as value.

Also is should have public methods that will allow setting and getting the value with the `req` argument.

**JwtService**
This will be used to create, sign, decode tokends, create cookies options and calculate the expiries of cookies.

**SessionService**
This will be used to create sessions, extract data from user agent and the forwarded ip from the `req` and also provide the session id for `refresh-token`.

**AuthenticationMiddleware**
This should use the `JwtService` to validate the tokens and as described in the Authentication Flow should handle all the cases.

This will be a global middleware and would run on all the endpoints except the one excluded.

In the **WebsocketsGateway** should be implemented code that will notify the user when a new session is created.

**Frontend**
Should have a composable that will take care of the authentication.

**Session Part Page**
We need to implement all the endpoints related to session management, and by the design from figma, the card that will allow to invalidate sessions.

**Task for cleaning stale session**
If by any scenario there will be a old session tied to nothing left in the database we should have a job that will clear that.

For a Proof of concept, you can access this repository: [nfa-jwt-poc](https://github.com/LwveMike/nfa-jwt-poc)
191 changes: 191 additions & 0 deletions apps/backend/database/seeds/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { config, exit } from 'node:process'
import { drizzle } from 'drizzle-orm/mysql2'
import { createConnection } from 'mysql2/promise'
import * as schema from 'src/modules/drizzle/schema'

async function main() {
const db = drizzle(
await createConnection({
host: 'localhost',
user: 'user',
password: 'user',
database: 'nfa',
port: 3306,
}),
{
mode: 'default',
schema,
},
)

async function case1() {
const { 0: role } = await db
.insert(schema.role)
.values({
name: 'user_role',
})

await db
.insert(schema.user)
.values({
username: 'username',
password: 'username',
roleId: role.insertId,
})

const { 0: service } = await db
.insert(schema.services)
.values({
name: 'protected',
})

const { 0: servicePermission1 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'read',
},
])

const { 0: servicePermission2 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'change',
},
])

const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId]

for (const id of lastServicePermissionIds) {
await db
.insert(schema.roleServicesPermissions)
.values([{
roleId: role.insertId,
servicePermissionId: id,
},
])
}
}

async function case2() {
const { 0: role } = await db
.insert(schema.role)
.values({
name: 'superadmin_role',
})

await db
.insert(schema.user)
.values({
username: 'superadmin',
password: 'superadmin',
roleId: role.insertId,
})

const { 0: service } = await db
.insert(schema.services)
.values({
name: 'api',
})

const { 0: servicePermission1 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'read',
},
])


const { 0: servicePermission2 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'change',
},
])

const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId]

for (const id of lastServicePermissionIds) {
await db
.insert(schema.roleServicesPermissions)
.values([{
roleId: role.insertId,
servicePermissionId: id,
},
])
}
}

async function case3() {
const { 0: role } = await db
.insert(schema.role)
.values({
name: 'random_role',
})

await db
.insert(schema.user)
.values({
username: 'randomuser',
password: 'randomuser',
roleId: role.insertId,
})

async function createService(name: string) {
const { 0: service } = await db
.insert(schema.services)
.values({
name,
})

const { 0: servicePermission1 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'read',
},
])

const { 0: servicePermission2 } = await db
.insert(schema.servicesPermission)
.values([
{
serviceId: service.insertId,
permission: 'change',
},
])

const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId]

for (const id of lastServicePermissionIds) {
await db
.insert(schema.roleServicesPermissions)
.values([{
roleId: role.insertId,
servicePermissionId: id,
},
])
}
}

for (const name of ['books', 'books.hello', 'random']) {
await createService(name)
}
}

await case1()
await case2()
await case3()

exit(0)
}

main()
5 changes: 5 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"date-fns": "^3.6.0",
"drizzle-orm": "^0.31.0",
"jsonwebtoken": "^9.0.2",
"lodash.groupby": "^4.6.0",
"lodash.set": "^4.3.2",
"mysql2": "^3.10.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
Expand All @@ -41,6 +43,8 @@
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash.groupby": "^4.6.9",
"@types/lodash.set": "^4.3.9",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.39",
Expand All @@ -50,6 +54,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"esno": "^4.7.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
Expand Down
9 changes: 1 addition & 8 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ import { SessionModule } from './modules/session/session.module'
ProtectedModule,
],
})
export class AppModule implements NestModule {
export class AppModule {
static readonly GLOBAL_PREFIX = '/api'

configure(consumer: MiddlewareConsumer) {
consumer
.apply(SessionMiddleware)
.exclude(...SessionMiddleware.EXCLUDE_ROUTES)
.forRoutes('*')
}
}
Loading

0 comments on commit d550f3f

Please sign in to comment.