Skip to content

Commit

Permalink
Added recaptcha (#1603)
Browse files Browse the repository at this point in the history
* Changed nginx routing config

* removed download from API

* fixed api limit tests

* [CreateAccount] added recaptcha

* Added captcha to download action

* added new api_usage info

* [ApiUsage] checking login_attempts

* added dt user tag to test

* fixed salt key

* added logging

* recaptcha callbacks

* removed invisible captcha

* removed invisible captcha
  • Loading branch information
lmacielvieira authored Dec 13, 2024
1 parent 9125bda commit 5832c64
Show file tree
Hide file tree
Showing 38 changed files with 521 additions and 41 deletions.
12 changes: 12 additions & 0 deletions docker/nginx/config/sites-enabled/default
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

# Include headers for client IP
proxy_set_header X-Real-IP $remote_addr; # Real client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Chain of client IPs
proxy_set_header Host $host;

proxy_pass http://$web_server;
client_max_body_size 5M;
include proxy-params.conf;
Expand Down Expand Up @@ -90,6 +96,12 @@ server {
location /graphql {
set $graphql graphql:3010;
proxy_pass http://$graphql$request_uri;

# Include headers for client IP
proxy_set_header X-Real-IP $remote_addr; # Real client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Chain of client IPs
proxy_set_header Host $host;

include proxy-params.conf;
}
location /api_auth {
Expand Down
4 changes: 4 additions & 0 deletions metaspace/engine/scripts/db_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ CREATE TABLE "public"."api_usage" (
"action_type" text NOT NULL,
"visibility" text NOT NULL,
"source" text NOT NULL,
"ip_hash" text NOT NULL,
"device_info" text NOT NULL,
"can_edit" boolean NOT NULL DEFAULT false,
"action_dt" TIMESTAMP NOT NULL,
CONSTRAINT "PK_bcaf2df186a22b1d135af4a5ac4" PRIMARY KEY ("id")
Expand All @@ -415,6 +417,8 @@ CREATE TABLE "graphql"."user" (
"role" text NOT NULL DEFAULT 'user',
"plan_id" integer NOT NULL,
"credentials_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
CONSTRAINT "REL_1b5eb1327a74d679537bdc1fa5" UNIQUE ("credentials_id"),
CONSTRAINT "REL_8f0a7cc334c3ec47f077cd63ac" UNIQUE ("plan_id"),
CONSTRAINT "PK_ea80e4e2bf12ab8b8b6fca858d7" PRIMARY KEY ("id")
Expand Down
4 changes: 2 additions & 2 deletions metaspace/engine/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ def fill_db(test_db, metadata, ds_config):
[(1, 'regular', datetime.now(), True)],
)
db.insert(
"INSERT INTO graphql.user (id, name, email, plan_id) VALUES (%s, %s, %s, %s)",
rows=[(user_id, 'name', '[email protected]', 1)],
"INSERT INTO graphql.user (id, name, email, plan_id, created_at, updated_at) VALUES (%s, %s, %s, %s, %s, %s)",
rows=[(user_id, 'name', '[email protected]', 1, datetime.now(), datetime.now())],
)
group_id = str(uuid.uuid4())
db.insert(
Expand Down
4 changes: 2 additions & 2 deletions metaspace/engine/tests/test_api_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ def fill_db(test_db):
[(1, 'regular', datetime.now(), True)],
)
db.insert(
'INSERT INTO graphql.user (id, name, email, plan_id) VALUES (%s, %s, %s, %s)',
[(USER_ID, 'name', '[email protected]', 1)],
'INSERT INTO graphql.user (id, name, email, plan_id, created_at, updated_at) VALUES (%s, %s, %s, %s, %s, %s)',
[(USER_ID, 'name', '[email protected]', 1, datetime.now(), datetime.now())],
)
db.insert(
'INSERT INTO graphql.group (id, name, short_name) VALUES (%s, %s, %s)',
Expand Down
5 changes: 3 additions & 2 deletions metaspace/engine/tests/test_es_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ def test_index_ds_works(sm_config, test_db, es, sm_index, ds_config, metadata, a
[(1, 'regular', datetime.now(), True)],
)
(user_id,) = db.insert_return(
"INSERT INTO graphql.user (email, name, role, plan_id) "
"VALUES ('email', 'user_name', 'user', 1) RETURNING id",
"INSERT INTO graphql.user (email, name, role, plan_id, created_at, updated_at) "
"VALUES ('email', 'user_name', 'user', 1, '2024-12-08 17:04:50.088000',"
" '2024-12-08 17:04:50.088000') RETURNING id",
[[]],
)
(group_id,) = db.insert_return(
Expand Down
5 changes: 5 additions & 0 deletions metaspace/graphql/config/config.js.template
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ config.redis.port = "{{ sm_graphql_redis_port }}";
config.cookie = {};
config.cookie.secret = "{{ sm_graphql_cookie_secret }}";

config.api = {}
config.api.usage = {}
config.api.usage.salt = "{{ sm_graphql_usage_salt }}";

config.google = {};
config.google.client_id = "{{ sm_graphql_google_client_id }}";
config.google.client_secret = "{{ sm_graphql_google_client_secret }}";
config.google.callback_url = "{{ sm_graphql_google_callback_url }}";
config.google.serpapi_key = "{{ sm_graphql_google_serpapi_key }}";
config.google.recaptcha_secret = "{{ sm_graphql_google_recaptcha_secret }}";

config.web_public_url = "{{ web_public_url }}";

Expand Down
7 changes: 6 additions & 1 deletion metaspace/graphql/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,15 @@ module.exports = {
dsn: null,
environment: 'default',
},

api: {
usage: {
salt: '',
},
},
google: {
client_id: '',
client_secret: '',
recaptcha_secret: '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe',
callback_url: 'http://localhost:8888/api_auth/google/callback',
},

Expand Down
5 changes: 5 additions & 0 deletions metaspace/graphql/config/development.docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ config.redis.port = '6379'
config.cookie = {}
config.cookie.secret = 'secret'

config.api = {}
config.api.usage = {}
config.api.usage.salt = ''

config.google = {}
config.google.client_id = ''
config.google.client_secret = ''
config.google.callback_url = ''
config.google.recaptcha_secret = ''

config.web_public_url = 'http://0.0.0.0:8999'

Expand Down
3 changes: 3 additions & 0 deletions metaspace/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"connect-redis": "^3.3.3",
"cors": "^2.8.1",
"cron": "^1.7.2",
"crypto-js": "^4.2.0",
"crypto-random-string": "^3.3.0",
"dataloader": "^1.4.0",
"express": "^4.20.0",
Expand Down Expand Up @@ -97,10 +98,12 @@
"ts-node": "^10.0.0",
"typeorm": "0.2.31",
"typescript": "^3.9.2",
"ua-parser-js": "^2.0.0",
"uuid": "^8.3.2",
"winston": "3.15.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/graphql": "^14.0.1",
"@types/jest": "^23.3.0",
"@types/node": "^8",
Expand Down
3 changes: 3 additions & 0 deletions metaspace/graphql/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ async function createHttpServerAsync(config, connection) {

configureSentryRequestHandler(app)

// enable trust proxy for capturing client IP address
app.set('trust proxy', true)

app.use(cors())
app.use(compression())

Expand Down
2 changes: 1 addition & 1 deletion metaspace/graphql/src/getContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const getBaseContext = (userFromRequest: JwtUser | UserModel | null, entityManag
contextUser.id = user.id
contextUser.role = user.role as ContextUserRole
contextUser.email = user.email || undefined
contextUser.planId = user.planId || undefined
contextUser.planId = user.planId || 1 // force limit to basic plan if not set
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class ApiUsageExtraInfo1733515541205 implements MigrationInterface {
name = 'ApiUsageExtraInfo1733515541205'

public async up(queryRunner: QueryRunner): Promise<void> {
// Add columns to the "user" table
await queryRunner.query(`ALTER TABLE "graphql"."user" ADD "created_at" TIMESTAMP DEFAULT NOW()`);
await queryRunner.query(`ALTER TABLE "graphql"."user" ADD "updated_at" TIMESTAMP DEFAULT NOW()`);

// Add columns to the "api_usage" table
await queryRunner.query(`ALTER TABLE "public"."api_usage" ADD "ip_hash" TEXT`);
await queryRunner.query(`ALTER TABLE "public"."api_usage" ADD "device_info" json`);

// Add indexes to the "api_usage" table
await queryRunner.query(`CREATE INDEX "IDX_api_usage_ip_hash" ON "public"."api_usage" ("ip_hash")`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Drop indexes from the "api_usage" table
await queryRunner.query(`DROP INDEX "public"."IDX_api_usage_ip_hash"`);

// Remove columns from the "api_usage" table
await queryRunner.query(`ALTER TABLE "public"."api_usage" DROP COLUMN "device_info"`);
await queryRunner.query(`ALTER TABLE "public"."api_usage" DROP COLUMN "ip_hash"`);

// Remove columns from the "user" table
await queryRunner.query(`ALTER TABLE "graphql"."user" DROP COLUMN "updated_at"`);
await queryRunner.query(`ALTER TABLE "graphql"."user" DROP COLUMN "created_at"`);
}
}
86 changes: 80 additions & 6 deletions metaspace/graphql/src/modules/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import { AuthMethodOptions } from '../../context'
import { UserError } from 'graphql-errors'
import NoisyJwtStrategy from './NoisyJwtStrategy'

import fetch from 'node-fetch'
import * as moment from 'moment'
import { getDeviceInfo, hashIp, performAction } from '../plan/util/canPerformAction'

const Uuid = superstruct.define<string>('Uuid', value => typeof value === 'string' && uuid.validate(value))

const preventCache = (req: Request, res: Response, next: NextFunction) => {
Expand Down Expand Up @@ -179,7 +183,7 @@ const configureApiKey = () => {
))
}

const configureLocalAuth = (router: IRouter<any>) => {
const configureLocalAuth = (router: IRouter<any>, entityManager: EntityManager) => {
Passport.use(new LocalStrategy(
{
usernameField: 'email',
Expand All @@ -201,18 +205,35 @@ const configureLocalAuth = (router: IRouter<any>) => {
))

router.post('/signin', function(req, res, next) {
Passport.authenticate('local', function(err, user) {
Passport.authenticate('local', async function(err, user) {
const action: any = {
actionType: 'login',
userId: user?.id,
type: 'user',
actionDt: moment.utc(moment.utc().toDate()),
deviceInfo: getDeviceInfo(req.headers?.['user-agent'], req.body?.user?.email),
canEdit: true,
ipHash: hashIp(req?.ip),
}

if (err) {
performAction({ entityManager } as any, action)
.finally(() => next(err))
next(err)
} else if (user) {
req.login(user, err => {
if (err) {
next()
action.actionType = 'login_attempt'
performAction({ entityManager } as any, action)
.finally(() => next(err))
} else {
res.status(200).send()
performAction({ entityManager } as any, action)
.finally(() => res.status(200).send())
}
})
} else {
action.actionType = 'login_attempt'
await performAction({ entityManager } as any, action)
res.status(401).send()
}
})(req, res, next)
Expand Down Expand Up @@ -341,18 +362,70 @@ const configureImpersonation = (router: IRouter<any>) => {
}
}

const verifyCaptcha = async(recaptchaToken: string) => {
const body = `secret=${encodeURIComponent(config.google.recaptcha_secret)}&response=${
encodeURIComponent(recaptchaToken)}`
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})

const data = await response.json()
if (!data.success) {
throw new UserError(JSON.stringify({
type: 'suspicious_activity',
message: 'Recaptcha checking failed. Please try again.',
}))
}
}

export const configureCaptcha = (router: IRouter<any>): void => {
const VerifyCaptchaBody = superstruct.type({
recaptchaToken: superstruct.string(),
})
router.post('/verify_captcha', async(req, res, next) => {
try {
const { recaptchaToken } = VerifyCaptchaBody.mask(req.body)

await verifyCaptcha(recaptchaToken)

res.status(200).json({ success: true, message: 'Captcha validated successfully.' })
} catch (err) {
next(err)
}
})
}

const configureCreateAccount = (router: IRouter<any>) => {
const CreateAccountBody = superstruct.type({
name: superstruct.string(),
email: superstruct.string(),
password: superstruct.string(),
recaptchaToken: superstruct.string(),
})
router.post('/createaccount', async(req, res, next) => {
const action: any = {
actionType: 'create',
type: 'user',
actionDt: moment.utc(moment.utc().toDate()),
deviceInfo: getDeviceInfo(req.headers?.['user-agent']),
ipHash: hashIp(req.ip),
}

try {
const { name, email, password } = CreateAccountBody.mask(req.body)
const { name, email, password, recaptchaToken } = CreateAccountBody.mask(req.body)

if (process.env.NODE_ENV !== 'test') {
await verifyCaptcha(recaptchaToken)
}

await createUserCredentials({ name, email, password })
res.send(true)
} catch (err) {
action.actionType = 'create_attempt'
next(err)
}
})
Expand Down Expand Up @@ -447,12 +520,13 @@ export const configureAuth = async(app: Express, entityManager: EntityManager) =
configurePassport(router, app)
configureJwt(router)
configureApiKey()
configureLocalAuth(router)
configureLocalAuth(router, entityManager)
configureGoogleAuth(router)
configureImpersonation(router)
// TODO: find a parameter validation middleware
configureCreateAccount(router)
configureResetPassword(router)
configureCaptcha(router)
configureReviewerAuth(router, entityManager)
app.use('/api_auth', router)
}
12 changes: 10 additions & 2 deletions metaspace/graphql/src/modules/auth/operation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
onAfterAll, onAfterEach,
onBeforeAll, onBeforeEach, testEntityManager,
} from '../../tests/graphqlTestEnvironment'
import { createTestPlan } from '../../tests/testDataCreation'
const mockEmail = _mockEmail as jest.Mocked<typeof _mockEmail>

async function createUserCredentialsEntities(user?: Partial<User>, cred?: Partial<Credentials>):
Expand Down Expand Up @@ -52,7 +53,14 @@ async function createUserCredentialsEntities(user?: Partial<User>, cred?: Partia
describe('Database operations with user', () => {
beforeAll(onBeforeAll)
afterAll(onAfterAll)
beforeEach(onBeforeEach)
beforeEach(async() => {
await onBeforeEach()
await createTestPlan({
name: 'regular',
isActive: true,
isDefault: true,
})
})
afterEach(onAfterEach)

describe('createUserCredentials', () => {
Expand All @@ -65,7 +73,7 @@ describe('Database operations with user', () => {

const cred = await testEntityManager.findOneOrFail(Credentials, {
select: ['id', 'hash', 'emailVerified'],
})
} as any)
expect(cred.id).toEqual(expect.anything())
expect(cred.hash).toEqual(expect.anything())
expect(cred.emailVerified).toEqual(false)
Expand Down
Loading

0 comments on commit 5832c64

Please sign in to comment.