Skip to content

Commit

Permalink
fix(idp): brute force protection
Browse files Browse the repository at this point in the history
  • Loading branch information
bouassaba committed Dec 9, 2024
1 parent 653af87 commit f7f91f3
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 28 deletions.
4 changes: 4 additions & 0 deletions idp/.env
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ PASSWORD_MIN_UPPERCASE=1
PASSWORD_MIN_NUMBERS=1
PASSWORD_MIN_SYMBOLS=1

# Security
SECURITY_MAX_FAILED_ATTEMPTS=5
SECURITY_LOCKOUT_PERIOD=300

# CORS
CORS_ORIGINS="http://127.0.0.1:3000"

Expand Down
14 changes: 14 additions & 0 deletions idp/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function getConfig(): Config {
readCORS(config)
readSearch(config)
readSMTP(config)
readSecurity(config)
}
return config
}
Expand Down Expand Up @@ -79,3 +80,16 @@ export function readSMTP(config: Config) {
config.smtp.senderAddress = process.env.SMTP_SENDER_ADDRESS
config.smtp.senderName = process.env.SMTP_SENDER_NAME
}

export function readSecurity(config: Config) {
if (process.env.SECURITY_MAX_FAILED_ATTEMPTS) {
config.security.maxFailedAttempts = parseInt(
process.env.SECURITY_MAX_FAILED_ATTEMPTS,
)
}
if (process.env.SECURITY_LOCKOUT_PERIOD) {
config.security.lockoutPeriod = parseInt(
process.env.SECURITY_LOCKOUT_PERIOD,
)
}
}
7 changes: 7 additions & 0 deletions idp/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class Config {
databaseURL: string
token: TokenConfig
password: PasswordConfig
security: SecurityConfig
corsOrigins: string[]
search: SearchConfig
smtp: SMTPConfig
Expand All @@ -23,6 +24,7 @@ export class Config {
this.password = new PasswordConfig()
this.search = new SearchConfig()
this.smtp = new SMTPConfig()
this.security = new SecurityConfig()
}
}

Expand All @@ -42,6 +44,11 @@ export class PasswordConfig {
minSymbols: number
}

export class SecurityConfig {
maxFailedAttempts: number
lockoutPeriod: number
}

export class SearchConfig {
url: string
}
Expand Down
2 changes: 2 additions & 0 deletions idp/src/infra/error/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum ErrorCode {
InvalidGrantType = 'invalid_grant_type',
PasswordValidationFailed = 'password_validation_failed',
UserSuspended = 'user_suspended',
UserTemporarilyLocked = 'user_locked',
UserIsNotAdmin = 'user_is_not_admin',
UserNotFound = 'user_not_found',
CannotSuspendLastAdmin = 'cannot_suspend_last_admin',
Expand All @@ -45,6 +46,7 @@ const statuses: { [key: string]: number } = {
[ErrorCode.InvalidGrantType]: 400,
[ErrorCode.PasswordValidationFailed]: 400,
[ErrorCode.UserSuspended]: 403,
[ErrorCode.UserTemporarilyLocked]: 429,
[ErrorCode.UserIsNotAdmin]: 403,
[ErrorCode.UserNotFound]: 404,
[ErrorCode.CannotSuspendLastAdmin]: 400,
Expand Down
8 changes: 8 additions & 0 deletions idp/src/infra/error/creators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export function newUserSuspendedError() {
})
}

export function newUserTemporarilyLockedError() {
return newError({
code: ErrorCode.UserTemporarilyLocked,
message: 'User temporarily locked. Try again later.',
userMessage: 'User temporarily locked. Try again later.',
})
}

export function newRefreshTokenExpiredError() {
return newError({
code: ErrorCode.RefreshTokenExpired,
Expand Down
47 changes: 36 additions & 11 deletions idp/src/token/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
newRefreshTokenExpiredError,
newUserIsNotAdminError,
newUserSuspendedError,
newUserTemporarilyLockedError,
} from '@/infra/error'
import { newHyphenlessUuid } from '@/infra/id'
import { verifyPassword } from '@/infra/password'
Expand Down Expand Up @@ -57,12 +58,17 @@ export async function exchange(options: TokenExchangeOptions): Promise<Token> {
if (!user.isActive) {
throw newUserSuspendedError()
}
if (verifyPassword(options.password, user.passwordHash)) {
await resetFailedAttempts(user.id)
return newToken(user.id, user.isAdmin)
console.log(JSON.stringify(user, null, 2))
if (isStillLocked(user)) {
throw newUserTemporarilyLockedError()
} else {
await increaseFailedAttempts(user.id)
throw newInvalidUsernameOrPasswordError()
if (verifyPassword(options.password, user.passwordHash)) {
await resetFailedAttemptsAndUnlock(user.id)
return newToken(user.id, user.isAdmin)
} else {
await increaseFailedAttemptsOrLock(user.id)
throw newInvalidUsernameOrPasswordError()
}
}
} else if (options.grant_type === 'refresh_token') {
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
Expand Down Expand Up @@ -148,18 +154,37 @@ function newAccessTokenExpiry(): number {
return Math.floor(now.getTime() / 1000)
}

async function increaseFailedAttempts(userId: string): Promise<void> {
async function increaseFailedAttemptsOrLock(userId: string): Promise<void> {
const user = await userRepo.findById(userId)
await userRepo.update({
id: user.id,
failedAttempts: user.failedAttempts + 1,
})
const failedAttempts = user.failedAttempts + 1
if (failedAttempts <= getConfig().security.maxFailedAttempts) {
await userRepo.update({
id: user.id,
failedAttempts,
})
} else {
await userRepo.update({
id: user.id,
lockedUntil: newLockoutUntil(),
})
}
}

async function resetFailedAttempts(userId: string): Promise<void> {
async function resetFailedAttemptsAndUnlock(userId: string): Promise<void> {
const user = await userRepo.findById(userId)
await userRepo.update({
id: user.id,
failedAttempts: 0,
lockedUntil: null,
})
}

function newLockoutUntil(): string {
const now = new Date()
now.setSeconds(now.getSeconds() + getConfig().security.lockoutPeriod)
return now.toISOString()
}

function isStillLocked(user: User): boolean {
return user.lockedUntil && new Date() < new Date(user.lockedUntil)
}
2 changes: 2 additions & 0 deletions idp/src/user/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type User = {
emailUpdateValue?: string
picture?: string
failedAttempts: number
lockedUntil?: string
createTime: string
updateTime?: string
}
Expand Down Expand Up @@ -62,6 +63,7 @@ export type UpdateOptions = {
emailUpdateValue?: string
picture?: string
failedAttempts?: number
lockedUntil?: string
createTime?: string
updateTime?: string
}
Expand Down
23 changes: 6 additions & 17 deletions idp/src/user/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ class UserRepoImpl {
email_update_value = $13,
picture = $14,
failed_attempts = $15,
update_time = $16
WHERE id = $17
locked_until = $16,
update_time = $17
WHERE id = $18
RETURNING *`,
[
entity.fullName,
Expand All @@ -214,6 +215,7 @@ class UserRepoImpl {
entity.emailUpdateValue,
entity.picture,
entity.failedAttempts,
entity.lockedUntil,
new Date().toISOString(),
entity.id,
],
Expand Down Expand Up @@ -268,28 +270,15 @@ class UserRepoImpl {
emailUpdateValue: row.email_update_value,
picture: row.picture,
failedAttempts: row.failed_attempts,
lockedUntil: row.locked_until,
createTime: row.create_time,
updateTime: row.update_time,
}
}

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
private mapList(list: any): User[] {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
return list.map((user: any) => {
return {
id: user.id,
fullName: user.full_name,
username: user.username,
email: user.email,
isEmailConfirmed: user.is_email_confirmed,
isAdmin: user.is_admin,
isActive: user.is_active,
picture: user.picture,
createTime: user.create_time,
updateTime: user.update_time,
}
})
return list.map(this.mapRow)
}
}

Expand Down
2 changes: 2 additions & 0 deletions migrations/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod m20240907_000001_add_user_force_change_password_field;
mod m20240913_000001_drop_segmentation_column;
mod m20241114_000001_drop_user_force_change_password_column;
mod m20241209_000001_add_user_failed_attempts_column;
mod m20241209_000001_add_user_locked_until_column;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
Expand All @@ -52,6 +53,7 @@ impl MigratorTrait for Migrator {
Box::new(m20240913_000001_drop_segmentation_column::Migration),
Box::new(m20241114_000001_drop_user_force_change_password_column::Migration),
Box::new(m20241209_000001_add_user_failed_attempts_column::Migration),
Box::new(m20241209_000001_add_user_locked_until_column::Migration),
]
}
}
50 changes: 50 additions & 0 deletions migrations/src/m20241209_000001_add_user_locked_until_column.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2023 Anass Bouassaba.
//
// Use of this software is governed by the Business Source License
// included in the file LICENSE in the root of this repository.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// AGPL-3.0-only in the root of this repository.
use sea_orm_migration::prelude::*;

use crate::models::v1::{User};

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(
&self,
manager: &SchemaManager,
) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(User::Table)
.add_column(ColumnDef::new(User::LockedUntil).text())
.to_owned(),
)
.await?;

Ok(())
}

async fn down(
&self,
manager: &SchemaManager,
) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(User::Table)
.drop_column(User::LockedUntil)
.to_owned(),
)
.await?;

Ok(())
}
}
1 change: 1 addition & 0 deletions migrations/src/models/v1/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub enum User {
ForceChangePassword,
Picture,
FailedAttempts,
LockedUntil,
CreateTime,
UpdateTime,
}

0 comments on commit f7f91f3

Please sign in to comment.