Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ See `backend/app/db/schema.sql`. Key tables:

## API Endpoints
OpenAPI: `backend/app/openapi.yaml`
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/security-alerts`
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
Expand Down Expand Up @@ -185,6 +185,7 @@ finmind/

## Security & Scalability
- JWT access/refresh, secure cookies OR Authorization header.
- Login anomaly detection records hashed network/device fingerprints, creates security alerts for new networks or devices, and exposes unread alerts through `/auth/security-alerts`.
- RBAC-ready via roles on `users.role`.
- N+1 avoided via SQLAlchemy eager loading.
- Redis caching for hot paths to cut DB load.
Expand Down
32 changes: 32 additions & 0 deletions app/src/__tests__/SignIn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,38 @@ describe('SignIn page', () => {
expect(navigateMock).toHaveBeenCalled();
});

it('shows security warning toast when login response is suspicious', async () => {
(login as jest.Mock).mockResolvedValue({
access_token: 'a',
refresh_token: 'r',
security_alert: {
suspicious: true,
reasons: ['new_ip'],
message: 'New login from an unrecognized network.',
},
});

render(
<MemoryRouter>
<SignIn />
</MemoryRouter>
);

await userEvent.type(screen.getByLabelText(/email/i), 'demo@finmind.local');
await userEvent.type(screen.getByLabelText(/password/i), 'DemoPass123!');
await userEvent.click(screen.getByRole('button', { name: /sign in to your account/i }));

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: 'destructive',
title: 'Suspicious login detected',
})
)
);
expect(navigateMock).toHaveBeenCalled();
});

it('shows error toast on failed login', async () => {
(login as jest.Mock).mockRejectedValue(new Error('invalid credentials'));

Expand Down
34 changes: 33 additions & 1 deletion app/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { api } from './client';

export type LoginResponse = { access_token: string; refresh_token?: string };
export type LoginSecurityAlert = {
suspicious: boolean;
reasons: string[];
alert_id?: number;
message?: string;
severity?: string;
};

export type LoginResponse = {
access_token: string;
refresh_token?: string;
security_alert?: LoginSecurityAlert;
};

export async function login(email: string, password: string): Promise<LoginResponse> {
return api<LoginResponse>('/auth/login', { method: 'POST', body: { email, password } });
Expand Down Expand Up @@ -35,3 +47,23 @@ export async function updateMe(payload: {
}): Promise<MeResponse> {
return api<MeResponse>('/auth/me', { method: 'PATCH', body: payload });
}

export type SecurityAlert = {
id: number;
type: string;
severity: string;
message: string;
details: Record<string, unknown>;
read: boolean;
read_at: string | null;
created_at: string | null;
};

export async function listSecurityAlerts(unreadOnly = false): Promise<SecurityAlert[]> {
const query = unreadOnly ? '?unread_only=true' : '';
return api<SecurityAlert[]>('/auth/security-alerts' + query);
}

export async function markSecurityAlertRead(alertId: number): Promise<SecurityAlert> {
return api<SecurityAlert>('/auth/security-alerts/' + alertId + '/read', { method: 'PATCH' });
}
18 changes: 14 additions & 4 deletions app/src/pages/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,20 @@ export function SignIn() {
} catch {
// Keep existing local currency if profile fetch fails.
}
toast({
title: 'Welcome back 👋',
description: 'You have successfully signed in.',
});
if (res.security_alert?.suspicious) {
toast({
variant: 'destructive',
title: 'Suspicious login detected',
description:
res.security_alert.message ||
'We noticed a login from a new device or network.',
});
} else {
toast({
title: 'Welcome back 👋',
description: 'You have successfully signed in.',
});
}
nav(from, { replace: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Invalid email or password';
Expand Down
61 changes: 60 additions & 1 deletion packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,69 @@ def _ensure_schema_compatibility(app: Flask) -> None:
NOT NULL DEFAULT 'INR'
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_hash VARCHAR(64) NOT NULL,
user_agent_hash VARCHAR(64) NOT NULL,
user_agent VARCHAR(255) NOT NULL,
suspicious BOOLEAN NOT NULL DEFAULT FALSE,
reason VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_events_user_created
ON login_events(user_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_events_user_ip
ON login_events(user_id, ip_hash)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_events_user_agent
ON login_events(user_id, user_agent_hash)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS security_alerts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
message VARCHAR(500) NOT NULL,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_security_alerts_user_created
ON security_alerts(user_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_security_alerts_user_unread
ON security_alerts(user_id, read)
"""
)
conn.commit()
except Exception:
app.logger.exception(
"Schema compatibility patch failed for users.preferred_currency"
"Schema compatibility patch failed for auth security tables"
)
conn.rollback()
finally:
Expand Down
28 changes: 28 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ CREATE TABLE IF NOT EXISTS users (
ALTER TABLE users
ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(10) NOT NULL DEFAULT 'INR';

CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_hash VARCHAR(64) NOT NULL,
user_agent_hash VARCHAR(64) NOT NULL,
user_agent VARCHAR(255) NOT NULL,
suspicious BOOLEAN NOT NULL DEFAULT FALSE,
reason VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_events_user_created ON login_events(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_events_user_ip ON login_events(user_id, ip_hash);
CREATE INDEX IF NOT EXISTS idx_login_events_user_agent ON login_events(user_id, user_agent_hash);

CREATE TABLE IF NOT EXISTS security_alerts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
message VARCHAR(500) NOT NULL,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_security_alerts_user_created ON security_alerts(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_security_alerts_user_unread ON security_alerts(user_id, read);

CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
Expand Down
25 changes: 25 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ class User(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginEvent(db.Model):
__tablename__ = "login_events"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
ip_hash = db.Column(db.String(64), nullable=False)
user_agent_hash = db.Column(db.String(64), nullable=False)
user_agent = db.Column(db.String(255), nullable=False)
suspicious = db.Column(db.Boolean, default=False, nullable=False)
reason = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class SecurityAlert(db.Model):
__tablename__ = "security_alerts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
alert_type = db.Column(db.String(50), nullable=False)
severity = db.Column(db.String(20), default="medium", nullable=False)
message = db.Column(db.String(500), nullable=False)
details = db.Column(db.JSON, default=dict, nullable=False)
read = db.Column(db.Boolean, default=False, nullable=False)
read_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class Category(db.Model):
__tablename__ = "categories"
id = db.Column(db.Integer, primary_key=True)
Expand Down
76 changes: 76 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ paths:
example:
access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
refresh_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
security_alert:
suspicious: false
reasons: []
'401':
description: Invalid credentials
content:
Expand Down Expand Up @@ -94,6 +97,54 @@ paths:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "Missing Authorization Header" }

/auth/security-alerts:
get:
summary: List login security alerts
tags: [Auth]
security: [{ bearerAuth: [] }]
parameters:
- in: query
name: unread_only
required: false
schema: { type: boolean }
responses:
'200':
description: Recent login anomaly alerts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SecurityAlert'
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/auth/security-alerts/{alertId}/read:
patch:
summary: Mark a security alert as read
tags: [Auth]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: alertId
required: true
schema: { type: integer }
responses:
'200':
description: Updated alert
content:
application/json:
schema:
$ref: '#/components/schemas/SecurityAlert'
'404':
description: Alert not found
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/categories:
get:
summary: List categories
Expand Down Expand Up @@ -498,6 +549,31 @@ components:
properties:
access_token: { type: string }
refresh_token: { type: string }
security_alert:
$ref: '#/components/schemas/LoginSecurityAlert'
LoginSecurityAlert:
type: object
properties:
suspicious: { type: boolean }
reasons:
type: array
items: { type: string, enum: [new_ip, new_device] }
alert_id: { type: integer, nullable: true }
message: { type: string, nullable: true }
severity: { type: string, nullable: true }
SecurityAlert:
type: object
properties:
id: { type: integer }
type: { type: string }
severity: { type: string }
message: { type: string }
details:
type: object
additionalProperties: true
read: { type: boolean }
read_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time, nullable: true }
Category:
type: object
properties:
Expand Down
Loading